Add a web extension and debian package compilation (#5)
Check go code / build-and-release (push) Successful in 1m9s

Reviewed-on: #5
Co-authored-by: Julien Fastré <julien.fastre@champs-libres.coop>
Co-committed-by: Julien Fastré <julien.fastre@champs-libres.coop>
This commit was merged in pull request #5.
This commit is contained in:
2025-11-12 19:10:41 +00:00
committed by Julien Fastré
parent d830f33eec
commit 0a64db6925
31 changed files with 7005 additions and 346 deletions
+223
View File
@@ -0,0 +1,223 @@
use cl_cli::cli::Issue2Work as Issue2WorkCli;
use cl_cli::config::build_config;
use cl_cli::error::GeneralError;
use cl_cli::planning::issue2work::handle_issue2work;
use serde::{Deserialize, Serialize};
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::process;
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum InputMessage {
Issue2Work(Issue2WorkWebExtMessage),
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
enum OutputContent {
Issue2Work(OutputIssue2Work),
}
#[derive(Debug, Serialize)]
#[serde(tag = "result", content = "data")]
enum OutputMessage {
Ok(OutputContent),
Error(String),
}
/// Input message for creating an issue.
#[derive(Debug, Deserialize)]
struct Issue2WorkWebExtMessage {
url: String,
project: String,
}
/// Output message returned to the web extension.
#[derive(Debug, Serialize)]
struct OutputIssue2Work {
created: String,
}
/// Reads a *raw* message from stdin (Native Messaging format).
/// - first reads 4 bytes (length)
/// - then reads exactly `len` bytes into a Vec<u8>
/// Returns Ok(None) if EOF (no more data).
fn read_message_bytes(stdin: &mut io::StdinLock<'_>) -> io::Result<Option<Vec<u8>>> {
let mut len_buf = [0u8; 4];
// Lire les 4 octets de longueur
match stdin.read_exact(&mut len_buf) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
// Plus de données : fin normale
return Ok(None);
}
Err(e) => return Err(e),
}
// Longueur annoncée (endianness native, comme dans la spec)
let len = u32::from_ne_bytes(len_buf) as usize;
// Ici, on peut recevoir un *nombre arbitraire d'octets* (dans la limite de u32).
let mut buf = vec![0u8; len];
stdin.read_exact(&mut buf)?;
Ok(Some(buf))
}
/// Write a JSON message to stdout with a header of 4 bytes.
fn write_json_message<T: serde::Serialize>(
stdout: &mut io::StdoutLock<'_>,
msg: &T,
) -> io::Result<()> {
let data =
serde_json::to_vec(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let len = data.len() as u32;
let len_buf = len.to_ne_bytes();
stdout.write_all(&len_buf)?;
stdout.write_all(&data)?;
stdout.flush()?;
Ok(())
}
/// "Logique métier" : construit une URL "created" à partir de l'entrée.
async fn handle_business_logic(input: InputMessage) -> Result<OutputContent, GeneralError> {
let mut default_config_path = PathBuf::new();
default_config_path.push(simple_home_dir::home_dir().unwrap());
default_config_path.push(".config/cl-cli/config.toml");
// during dev: default_config_path.push("/tmp/cl-cli/config.toml");
let config = match build_config(&default_config_path) {
Ok(c) => c,
Err(e) => {
return Err(e.into());
}
};
let result = match input {
InputMessage::Issue2Work(i2w) => {
let arg = Issue2WorkCli {
issue_url: i2w.url,
project_id: i2w.project,
assign_to_me: false,
};
let result = handle_issue2work(config, &arg).await?;
OutputContent::Issue2Work(OutputIssue2Work {
created: result.work_package_url,
})
}
};
Ok(result)
}
#[tokio::main]
async fn main() {
eprintln!("Native host Rust démarré.");
let stdin = io::stdin();
let mut stdin_lock = stdin.lock();
let stdout = io::stdout();
let mut stdout_lock = stdout.lock();
loop {
match read_message_bytes(&mut stdin_lock) {
Ok(Some(raw_bytes)) => {
eprintln!("Reçu {} octets depuis l'extension.", raw_bytes.len());
// On parse le JSON *après* avoir lu tous les octets.
let input: InputMessage = match serde_json::from_slice(&raw_bytes) {
Ok(v) => v,
Err(e) => {
eprintln!("JSON invalide reçu: {e}");
// Option : renvoyer une erreur JSON au lieu d'abandonner
continue;
}
};
// Logique métier -> OutputMessage
let output = match handle_business_logic(input).await {
Ok(v) => OutputMessage::Ok(v),
Err(e) => OutputMessage::Error(e.description),
};
if let Err(e) = write_json_message(&mut stdout_lock, &output) {
eprintln!("Erreur lors de l'écriture de la réponse: {e}");
// Si on ne sait plus écrire, on termine.
process::exit(1);
}
}
Ok(None) => {
eprintln!("EOF sur stdin, arrêt du host.");
break;
}
Err(e) => {
eprintln!("Erreur de lecture: {e}");
process::exit(1);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn deserializes_issue2work_variant() {
// Given: a JSON with the external tag "type" matching the enum variant name
let payload = json!({
"type": "Issue2Work",
"url": "https://example.com/issues/123",
"project": "test"
});
// When: deserializing to InputMessage
let msg: InputMessage = serde_json::from_value(payload).expect("valid enum JSON");
// Then: we get the correct variant and fields
match msg {
InputMessage::Issue2Work(inner) => {
assert_eq!(inner.url, "https://example.com/issues/123");
assert_eq!(inner.project, "test");
}
}
}
#[test]
fn fails_on_unknown_type_tag() {
let payload = json!({
"type": "Unknown",
"url": "https://example.com/issues/123",
"project": "test"
});
let err = serde_json::from_value::<InputMessage>(payload).unwrap_err();
// Basic sanity check that it's a data error mentioning the unrecognized variant
let msg = err.to_string();
assert!(
msg.contains("unknown variant") || msg.contains("unknown") || msg.contains("expected"),
"unexpected error message: {msg}"
);
}
#[test]
fn fails_when_missing_required_fields() {
// Missing "url" and "project"
let payload = json!({
"type": "Issue2Work"
});
let err = serde_json::from_value::<InputMessage>(payload).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("missing field"),
"unexpected error message: {msg}"
);
}
}