From c45e12ed9a95e5e8f6f56c2b79d67266663f1951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 7 Nov 2025 22:39:06 +0100 Subject: [PATCH] Setup a web extension and refactor code --- Cargo.lock | 1 + Cargo.toml | 9 ++ src/cli.rs | 8 +- src/config.rs | 41 +++++++++- src/debug.rs | 2 +- src/error.rs | 2 +- src/gitea/action.rs | 22 ++--- src/gitlab/action.rs | 21 +++-- src/gitlab/issue.rs | 1 + src/lib.rs | 9 ++ src/main.rs | 37 +++------ src/planning/issue2work.rs | 36 ++++++-- src/planning/mod.rs | 14 +++- src/webext.rs | 163 +++++++++++++++++++++++++++++++++++++ 14 files changed, 306 insertions(+), 60 deletions(-) create mode 100644 src/lib.rs create mode 100644 src/webext.rs diff --git a/Cargo.lock b/Cargo.lock index 95baffe..a8e8932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,7 @@ dependencies = [ "open", "reqwest", "serde", + "serde_json", "simple-home-dir", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index 5d616d3..262f018 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,14 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[[bin]] +name="cl-cli" +path="src/main.rs" + +[[bin]] +name="webext" +path="src/webext.rs" + [dependencies] clap = { version = "4.1.13", features = ["derive"] } gitlab = "0.1804.0" @@ -16,3 +24,4 @@ tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] } simple-home-dir = "0.5.2" log = "0.4.28" open = "5.3.2" +serde_json = "1.0.145" diff --git a/src/cli.rs b/src/cli.rs index 44edd08..9403464 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; #[derive(Parser)] -pub(crate) struct Cli { +pub struct Cli { #[arg(short, long, value_name = "FILE")] pub config: Option, @@ -12,19 +12,19 @@ pub(crate) struct Cli { } #[derive(Subcommand)] -pub(crate) enum Commands { +pub enum Commands { #[command(subcommand)] Planning(Planning), Test, } #[derive(Subcommand)] -pub(crate) enum Planning { +pub enum Planning { I2work(Issue2Work), } #[derive(Args, Debug)] -pub(crate) struct Issue2Work { +pub struct Issue2Work { pub issue_url: String, pub project_id: String, #[arg(short, long)] diff --git a/src/config.rs b/src/config.rs index 9cd9fdd..38a1dc0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,26 +1,59 @@ +use crate::error::GeneralError; use serde::Deserialize; +use std::fs; +use std::path::Path; #[derive(Deserialize, Debug)] -pub(crate) struct Config { +pub struct Config { pub gitlab: Vec, pub gitea: Vec, pub openproject: OpenProjectConfig, } #[derive(Deserialize, Debug)] -pub(crate) struct GitlabConfig { +pub struct GitlabConfig { pub token: String, pub domain: String, } #[derive(Deserialize, Debug)] -pub(crate) struct GiteaConfig { +pub struct GiteaConfig { pub token: String, pub domain: String, } #[derive(Deserialize, Debug)] -pub(crate) struct OpenProjectConfig { +pub struct OpenProjectConfig { pub token: String, pub base_url: String, } + +pub struct BuildConfigError { + msg: String, +} + +impl Into for BuildConfigError { + fn into(self) -> GeneralError { + GeneralError { + description: format!("Could not build config: {}", self.msg), + } + } +} + +pub fn build_config(config_path: &Path) -> Result { + let config_path_content = match fs::read_to_string(config_path) { + Ok(content) => content, + Err(e) => { + return Err(BuildConfigError { + msg: format!( + "Could not read a config file at {:?}, error: {}", + config_path, e + ), + }); + } + }; + + let config: Config = toml::from_str(&*config_path_content).expect("Could not parse config"); + + Ok(config) +} diff --git a/src/debug.rs b/src/debug.rs index e0553e1..6de193e 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -3,7 +3,7 @@ use crate::error::GeneralError; use crate::gitea::issue::Issue; use url::Url; -pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> { +pub async fn debug(config: Config) -> Result<(), GeneralError> { println!("test"); let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap()); diff --git a/src/error.rs b/src/error.rs index ed64e83..21ad28f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,7 +3,7 @@ use reqwest::Error; #[derive(Debug)] pub struct GeneralError { - pub(crate) description: String, + pub description: String, } impl From for GeneralError { diff --git a/src/gitea/action.rs b/src/gitea/action.rs index 77bc941..309119b 100644 --- a/src/gitea/action.rs +++ b/src/gitea/action.rs @@ -7,12 +7,18 @@ use crate::openproject::user::{GetMe, User}; use crate::openproject::work::WorkPackageWriter; use crate::planning::utils::{append_related_issues, IssueRelated}; use crate::planning::Issue2WorkActionTrait; +use crate::planning::Issue2WorkResult; use url::Url; pub(crate) struct GiteaAction {} impl Issue2WorkActionTrait for GiteaAction { - async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError> { + async fn run( + &self, + url: &Url, + config: &Config, + args: &Issue2Work, + ) -> Result { let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap()); let issue: Issue = gitea_client.get(issue_html_url_to_api(url)?).await?; let open_project_client = @@ -49,16 +55,10 @@ impl Issue2WorkActionTrait for GiteaAction { ) .await?; - println!( - "new work package created: {:?}, edit at {}", - work_package.subject, url_wp - ); - - if let Err(e) = open::that(url_wp) { - println!("failed to open work package in browser: {}", e); - }; - - Ok(()) + Ok(Issue2WorkResult { + work_package_url: url_wp, + subject: work_package.subject, + }) } fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool { diff --git a/src/gitlab/action.rs b/src/gitlab/action.rs index c7e34d9..4cf877e 100644 --- a/src/gitlab/action.rs +++ b/src/gitlab/action.rs @@ -7,6 +7,7 @@ use crate::openproject::client::Client; use crate::openproject::user::{GetMe, User}; use crate::openproject::work::WorkPackageWriterAssignee; use crate::planning::Issue2WorkActionTrait; +use crate::planning::Issue2WorkResult; use gitlab::api::{issues, projects, AsyncQuery}; use url::Url; @@ -26,7 +27,12 @@ struct Issue2WorkPackageDTO { pub(crate) struct GitlabAction {} impl Issue2WorkActionTrait for GitlabAction { - async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError> { + async fn run( + &self, + url: &Url, + config: &Config, + args: &Issue2Work, + ) -> Result { let client = client_for_url(&url, &config).await?; let data = extract_issue_info(&url).unwrap(); @@ -65,12 +71,15 @@ impl Issue2WorkActionTrait for GitlabAction { .create_work_package(&(&dto).into(), &args.project_id) .await?; - println!( - "new work package created: {:?}, edit at {}/projects/{}/work_packages/{}", - work_package.subject, config.openproject.base_url, args.project_id, work_package.id - ); + let result = Issue2WorkResult { + work_package_url: format!( + "{}/projects/{}/work_packages/{}", + config.openproject.base_url, args.project_id, work_package.id + ), + subject: work_package.subject, + }; - Ok(()) + Ok(result) } fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool { diff --git a/src/gitlab/issue.rs b/src/gitlab/issue.rs index 068a657..00a87e6 100644 --- a/src/gitlab/issue.rs +++ b/src/gitlab/issue.rs @@ -6,6 +6,7 @@ pub struct Issue { pub title: String, pub web_url: String, pub project_id: u64, + pub description: String, } #[derive(Clone, Debug, Deserialize)] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..49ec6d3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +// Regroupe la logique commune accessible par les deux binaires +pub mod cli; +pub mod config; +pub mod debug; +pub mod error; +pub mod gitea; +pub mod gitlab; +pub mod openproject; +pub mod planning; diff --git a/src/main.rs b/src/main.rs index fc7ff5f..2591cd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,22 +3,11 @@ extern crate reqwest; extern crate serde; extern crate simple_home_dir; -mod cli; -mod config; -mod debug; -mod error; -mod gitea; -mod gitlab; -mod openproject; -mod planning; - -use crate::cli::Commands::{Planning, Test}; -use crate::cli::Planning::I2work; -use crate::error::GeneralError; +use cl_cli::cli::Cli; +use cl_cli::cli::Commands::{Planning, Test}; +use cl_cli::cli::Planning::I2work; +use cl_cli::error::GeneralError; use clap::Parser; -use cli::Cli; -use config::Config; -use std::fs; use std::path::PathBuf; use std::process::exit; @@ -34,22 +23,20 @@ async fn main() { None => &default_config_path, }; - let config_path_content = match fs::read_to_string(config_path) { - Ok(content) => content, + let config = match cl_cli::config::build_config(config_path) { + Ok(c) => c, Err(e) => { - println!( - "Could not read config file at {:?}, error: {}", - config_path, e - ); + let general_error: GeneralError = e.into(); + println!("{}", general_error.description); exit(1); } }; - let config: Config = toml::from_str(&*config_path_content).expect("Could not parse config"); - let result = match cli.command { - Some(Planning(I2work(args))) => planning::issue2work::issue2work(config, &args).await, - Some(Test) => debug::debug(config).await, + Some(Planning(I2work(args))) => { + cl_cli::planning::issue2work::issue2work_cli(config, &args).await + } + Some(Test) => cl_cli::debug::debug(config).await, None => Err(GeneralError { description: "No command launched".to_string(), }), diff --git a/src/planning/issue2work.rs b/src/planning/issue2work.rs index 2e1189b..0075d2c 100644 --- a/src/planning/issue2work.rs +++ b/src/planning/issue2work.rs @@ -3,7 +3,7 @@ use crate::config::Config; use crate::error::GeneralError; use crate::gitea::action::GiteaAction; use crate::gitlab::action::GitlabAction; -use crate::planning::Issue2WorkActionTrait; +use crate::planning::{Issue2WorkActionTrait, Issue2WorkResult}; use url::Url; struct App { @@ -11,20 +11,44 @@ struct App { gitea_issue2work_action: GiteaAction, } -pub(crate) async fn issue2work(config: Config, args: &Issue2Work) -> Result<(), GeneralError> { +pub async fn handle_issue2work( + config: Config, + args: &Issue2Work, +) -> Result { let url = Url::parse(&*args.issue_url).expect("issue_url is not valid"); let app = App { gitlab_issue2work_action: GitlabAction {}, gitea_issue2work_action: GiteaAction {}, }; + let result: Issue2WorkResult; if app.gitlab_issue2work_action.supports(&url, &config, args) { - app.gitlab_issue2work_action.run(&url, &config, args).await + result = app + .gitlab_issue2work_action + .run(&url, &config, args) + .await?; } else if app.gitea_issue2work_action.supports(&url, &config, args) { - app.gitea_issue2work_action.run(&url, &config, args).await + result = app.gitea_issue2work_action.run(&url, &config, args).await? } else { - Err(GeneralError { + return Err(GeneralError { description: format!("This action is not supported for this url: {}", url), - }) + }); } + + Ok(result) +} + +pub async fn issue2work_cli(config: Config, args: &Issue2Work) -> Result<(), GeneralError> { + let result = handle_issue2work(config, args).await?; + + println!( + "new work package created: {:?}, edit at {}", + result.subject, result.work_package_url + ); + + if let Err(e) = open::that(result.work_package_url.as_str()) { + println!("failed to open work package in browser: {}", e); + }; + + Ok(()) } diff --git a/src/planning/mod.rs b/src/planning/mod.rs index 775625e..a09d44f 100644 --- a/src/planning/mod.rs +++ b/src/planning/mod.rs @@ -3,11 +3,21 @@ use crate::config::Config; use crate::error::GeneralError; use url::Url; -pub(crate) mod issue2work; +pub mod issue2work; pub mod utils; pub trait Issue2WorkActionTrait { - async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError>; + fn run( + &self, + url: &Url, + config: &Config, + args: &Issue2Work, + ) -> impl std::future::Future> + Send; fn supports(&self, url: &Url, config: &Config, args: &Issue2Work) -> bool; } + +pub struct Issue2WorkResult { + pub work_package_url: String, + pub subject: String, +} diff --git a/src/webext.rs b/src/webext.rs new file mode 100644 index 0000000..6287619 --- /dev/null +++ b/src/webext.rs @@ -0,0 +1,163 @@ +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", content = "data")] +enum InputMessage { + Issue2Work(Issue2WorkWebExtMessage), +} + +#[derive(Debug, Serialize)] +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"); + 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); + } + } + } +}