//! [`CliAgentRuntime`] — the single, generic [`AgentRuntime`] adapter driven by //! an [`AgentProfile`] (ARCHITECTURE §5, §4; CONTEXT §9). //! //! There is **one** adapter for *every* AI CLI: the diversity (Claude, Codex, //! Gemini, Aider, custom) lives entirely in the declarative [`AgentProfile`] //! data, never in code. Adding an AI = adding a profile (the **Open/Closed** //! principle made literal). //! //! # Responsibilities //! //! - [`detect`](AgentRuntime::detect): run the profile's detection command via //! the injected [`ProcessSpawner`] and report whether the CLI is installed //! (exit code 0). When `profile.detect` is `None`, fall back to //! ` --version` (see [`detection_spec`]). //! - [`prepare_invocation`](AgentRuntime::prepare_invocation): a **pure** //! function (no I/O) that builds the [`SpawnSpec`] — command, args, resolved //! `cwd` (template substitution), and the context-injection plan derived from //! the profile's [`ContextInjection`]. This is the testable core of L5. use std::sync::Arc; use async_trait::async_trait; use domain::ports::{ AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SpawnSpec, }; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::ProjectPath; /// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only /// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure. #[derive(Clone)] pub struct CliAgentRuntime { spawner: Arc, } impl CliAgentRuntime { /// Builds the runtime from an injected [`ProcessSpawner`] (the composition /// root passes a [`crate::LocalProcessSpawner`], or a remote one later). #[must_use] pub fn new(spawner: Arc) -> Self { Self { spawner } } /// Builds the [`SpawnSpec`] used to *detect* a profile's CLI. /// /// - If `profile.detect` is set, it is parsed as a whitespace-delimited /// command line (`"claude --version"` → command `claude`, args /// `["--version"]`). The first token is the executable. /// - Otherwise we fall back to ` --version`, the near-universal /// "is it installed?" probe. /// /// Detection runs in the current working directory and injects nothing. /// /// This is pure (no I/O) and therefore unit-testable without a spawner. /// /// # Errors /// [`RuntimeError::Detection`] if the detection command is blank. pub fn detection_spec(profile: &AgentProfile) -> Result { let line = profile .detect .clone() .unwrap_or_else(|| format!("{} --version", profile.command)); let mut tokens = line.split_whitespace(); let command = tokens .next() .ok_or_else(|| RuntimeError::Detection("empty detection command".to_owned()))? .to_owned(); let args = tokens.map(str::to_owned).collect(); // Detection runs in a neutral cwd; "." is a safe relative placeholder the // spawner resolves against the process cwd. let cwd = ProjectPath::new("/") .map_err(|e| RuntimeError::Detection(e.to_string()))?; Ok(SpawnSpec { command, args, cwd, env: Vec::new(), context_plan: None, }) } /// Resolves the profile's `cwd_template` against the project root. /// /// The only recognised placeholder is `{projectRoot}` (CONTEXT §9). An empty /// template defaults to the project root itself. fn resolve_cwd(profile: &AgentProfile, root: &ProjectPath) -> Result { let template = profile.cwd_template.trim(); if template.is_empty() { return Ok(root.clone()); } let resolved = template.replace("{projectRoot}", root.as_str()); ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string())) } /// Builds the [`ContextInjectionPlan`] for a given [`ContextInjection`] and /// the prepared context. **Pure** — the testable heart of L5. /// /// | `ContextInjection` | `ContextInjectionPlan` | /// |---------------------------|----------------------------------------------------------| /// | `ConventionFile { target }` | `File { target }` — caller writes the `.md` there | /// | `Flag { flag }` | `Args { args }` — `{path}` substituted with the ctx path | /// | `Stdin` | `Stdin` — content piped on stdin | /// | `Env { var }` | `Env { var }` — content/path delivered via env var | /// /// For `Flag`, the flag template may contain `{path}` (replaced by the /// context's relative path). A flag *without* `{path}` is treated as a /// standalone switch followed by the path as a separate argument /// (e.g. `-f` → `["-f", ""]`); a flag *with* `{path}` is split on /// whitespace after substitution (e.g. `--context-file {path}` → /// `["--context-file", ""]`). fn injection_plan( injection: &ContextInjection, ctx: &PreparedContext, ) -> ContextInjectionPlan { match injection { ContextInjection::ConventionFile { target } => ContextInjectionPlan::File { target: target.clone(), }, ContextInjection::Flag { flag } => { let path = &ctx.relative_path; let args = if flag.contains("{path}") { flag.replace("{path}", path) .split_whitespace() .map(str::to_owned) .collect() } else { vec![flag.clone(), path.clone()] }; ContextInjectionPlan::Args { args } } ContextInjection::Stdin => ContextInjectionPlan::Stdin, ContextInjection::Env { var } => ContextInjectionPlan::Env { var: var.clone() }, } } } #[async_trait] impl AgentRuntime for CliAgentRuntime { fn detect(&self, profile: &AgentProfile) -> Result { let spec = Self::detection_spec(profile)?; // The port is synchronous but the spawner is async; block on it. The // adapter is invoked off the Tauri async runtime (detection is a // short-lived probe), so a transient runtime here is acceptable and keeps // the `AgentRuntime` port plain-`fn` as the domain declares it. let spawner = Arc::clone(&self.spawner); let output = futures_block_on(async move { spawner.run(spec).await }) .map_err(|e| RuntimeError::Detection(e.to_string()))?; Ok(output.status.code == Some(0)) } fn prepare_invocation( &self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath, ) -> Result { let resolved_cwd = Self::resolve_cwd(profile, cwd)?; let plan = Self::injection_plan(&profile.context_injection, ctx); // For the `Flag` strategy the context path travels *on the command line*, // so fold those args into the spec's args (after the profile's static // args). The other strategies leave args untouched; the plan tells the // launcher (L6) what else to do (write file / pipe stdin / set env). let mut args = profile.args.clone(); if let ContextInjectionPlan::Args { args: extra } = &plan { args.extend(extra.iter().cloned()); } Ok(SpawnSpec { command: profile.command.clone(), args, cwd: resolved_cwd, env: Vec::new(), context_plan: Some(plan), }) } } /// Minimal block-on helper that drives a future to completion on a fresh /// current-thread tokio runtime. Used only by [`CliAgentRuntime::detect`], whose /// port signature is synchronous while the [`ProcessSpawner`] it calls is async. fn futures_block_on(fut: F) -> F::Output { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("failed to build a current-thread runtime for detection") .block_on(fut) }