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 /// Returns Ok(None) if EOF (no more data). fn read_message_bytes(stdin: &mut io::StdinLock<'_>) -> io::Result>> { 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( 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 { 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::(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::(payload).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("missing field"), "unexpected error message: {msg}" ); } }