Skip to main content

spar/
agent.rs

1//! Driving one CLI.
2//!
3//! An agent is a command template plus an output adapter, not a class.
4//! Supporting a new CLI is a preset file, which is the difference between a
5//! tool that works for its author and one that works for anyone who installs
6//! it.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12use serde_json::Value;
13
14use crate::config::{AgentSpec, CommandPart, OutputMode, SystemVia};
15use crate::error::{Result, SparError};
16use crate::jsonx;
17use crate::proc::{self, ExecOpts};
18use crate::{bail, spar_err};
19
20/// Injected into every request. Prompting alone is not sufficient for any of
21/// these, which is why each one is also enforced deterministically on the way
22/// out, but a model that was asked produces less for the gate to fix.
23pub const STYLE_RULES: &str = "\
24Style rules for every artifact you produce (commits, PR titles, PR bodies, issue
25titles, issue bodies, review comments):
26- Never use em-dashes or en-dashes. Use commas, colons, or parentheses.
27- Never mention Claude, Codex, OpenAI, ChatGPT, Anthropic, AI, or any tooling
28  used to produce the work.
29- Never add a Co-Authored-By trailer or a \"Generated with\" footer to commits.
30- Be brief. A human engineer with other work has to read this. Lead with the
31  point, cut the preamble, stop when you are done. Do not restate the task, do
32  not announce what you are about to do, do not summarise what the diff already
33  shows. One sentence beats one paragraph.
34- No headings, bullet lists, or bold text in anything only a few sentences long.
35Write as a human engineer would, because the reader neither knows nor cares what
36produced the work.";
37
38const JSON_INSTRUCTION: &str = "Respond with ONLY a JSON object matching this \
39schema. No prose, no markdown fences, no commentary before or after:";
40
41pub struct Agent {
42    pub spec: AgentSpec,
43    resolved: OnceLock<PathBuf>,
44}
45
46impl std::fmt::Debug for Agent {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(f, "<{} {}>", self.spec.name, self.spec.describe())
49    }
50}
51
52impl Agent {
53    pub fn new(spec: AgentSpec) -> Self {
54        Self {
55            spec,
56            resolved: OnceLock::new(),
57        }
58    }
59
60    pub fn name(&self) -> &str {
61        &self.spec.name
62    }
63
64    /// Used by the tests, and by `doctor` when it wants to report a path it
65    /// already knows.
66    #[doc(hidden)]
67    pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
68        let agent = Self::new(spec);
69        let _ = agent.resolved.set(bin.into());
70        agent
71    }
72
73    // -- binary resolution -------------------------------------------------
74
75    /// `SPAR_<NAME>_BIN` first, then the template's own program name on PATH or
76    /// as an absolute path, then the preset's search paths. Never guess
77    /// silently: a miss reports every location tried, because a tool that
78    /// quietly runs the wrong binary is worse than one that fails.
79    pub fn resolve_bin(&self) -> Result<&Path> {
80        if let Some(found) = self.resolved.get() {
81            return Ok(found.as_path());
82        }
83        let found = self.locate()?;
84        let _ = self.resolved.set(found);
85        Ok(self.resolved.get().expect("just set").as_path())
86    }
87
88    fn locate(&self) -> Result<PathBuf> {
89        let wanted = match self.spec.command.first() {
90            Some(CommandPart::One(program)) => program.clone(),
91            _ => bail!("agent '{}' has no command configured", self.spec.name),
92        };
93
94        let env_key = format!(
95            "SPAR_{}_BIN",
96            self.spec.name.to_uppercase().replace('-', "_")
97        );
98        let env_override = std::env::var(&env_key)
99            .ok()
100            .filter(|v| !v.trim().is_empty());
101
102        let mut tried: Vec<String> = Vec::new();
103
104        for candidate in env_override
105            .iter()
106            .map(String::as_str)
107            .chain([wanted.as_str()])
108        {
109            let path = Path::new(candidate);
110            if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
111                let expanded = proc::expand_tilde(candidate);
112                tried.push(expanded.display().to_string());
113                if proc::is_executable(&expanded) {
114                    return Ok(expanded);
115                }
116            } else {
117                tried.push(format!("{candidate} (PATH)"));
118                if let Some(found) = proc::which(candidate) {
119                    return Ok(found);
120                }
121            }
122        }
123
124        for base in &self.spec.search_paths {
125            let base = proc::expand_tilde(base);
126            let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
127                base
128            } else {
129                base.join(&wanted)
130            };
131            tried.push(candidate.display().to_string());
132            if proc::is_executable(&candidate) {
133                return Ok(candidate);
134            }
135        }
136
137        Err(spar_err!(
138            "could not find the binary for agent '{}'. Tried:\n  {}\nSet agents.{}.command[0] to \
139             an absolute path, or {}=/path/to/binary.",
140            self.spec.name,
141            tried.join("\n  "),
142            self.spec.name,
143            env_key
144        ))
145    }
146
147    // -- command rendering -------------------------------------------------
148
149    /// Substitute placeholders. A group whose placeholder is unset is dropped
150    /// whole, so omitting `model` drops `--model` with it rather than passing
151    /// an empty string that the CLI would reject or, worse, accept.
152    pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
153        let mut out = vec![self.resolve_bin()?.display().to_string()];
154        for part in self.spec.command.iter().skip(1) {
155            let mut rendered = Vec::new();
156            let mut skip = false;
157            for arg in part.args() {
158                match values.substitute(arg) {
159                    Some(text) => rendered.push(text),
160                    None => {
161                        skip = true;
162                        break;
163                    }
164                }
165            }
166            if !skip {
167                out.extend(rendered);
168            }
169        }
170        Ok(out)
171    }
172
173    /// True when the template has somewhere to put a schema file, meaning the
174    /// CLI can do structured output natively.
175    pub fn supports_schema(&self) -> bool {
176        self.spec
177            .command
178            .iter()
179            .flat_map(|p| p.args())
180            .any(|a| a.contains("{schema_file}"))
181    }
182
183    // -- output adapters ---------------------------------------------------
184
185    pub fn extract(&self, stdout: &str) -> Result<String> {
186        match self.spec.output {
187            OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
188            OutputMode::Jsonl => self.extract_jsonl(stdout),
189        }
190    }
191
192    fn extract_jsonl(&self, stdout: &str) -> Result<String> {
193        let mut messages: Vec<String> = Vec::new();
194        let mut errors: Vec<String> = Vec::new();
195
196        for line in stdout.lines() {
197            let line = line.trim();
198            if !line.starts_with('{') {
199                continue;
200            }
201            let Ok(event) = serde_json::from_str::<Value>(line) else {
202                continue;
203            };
204            if matches(&event, &self.spec.message_match) {
205                if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
206                    if let Some(text) = as_text(text) {
207                        messages.push(text);
208                    }
209                }
210            } else if matches!(
211                event.get("type").and_then(Value::as_str),
212                Some("turn.failed") | Some("error")
213            ) {
214                errors.push(truncate(&event.to_string(), 400));
215            }
216        }
217
218        if messages.is_empty() && !errors.is_empty() {
219            bail!("agent '{}' failed: {}", self.spec.name, errors.join("; "));
220        }
221        Ok(messages.join("\n").trim().to_string())
222    }
223
224    // -- the two operations everything else is built from -------------------
225
226    pub fn ask(&self, prompt: &str, cwd: &Path, effort: Option<&str>) -> Result<String> {
227        self.ask_inner(prompt, cwd, effort, None)
228    }
229
230    fn ask_inner(
231        &self,
232        prompt: &str,
233        cwd: &Path,
234        effort: Option<&str>,
235        schema_file: Option<&Path>,
236    ) -> Result<String> {
237        let body = match self.spec.system_via {
238            SystemVia::Placeholder => prompt.to_string(),
239            SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
240        };
241        let values = Placeholders {
242            prompt: Some(body),
243            system: Some(STYLE_RULES.to_string()),
244            model: self.spec.model.clone(),
245            effort: effort
246                .map(str::to_string)
247                .or_else(|| self.spec.effort.clone()),
248            cwd: Some(cwd.display().to_string()),
249            schema_file: schema_file.map(|p| p.display().to_string()),
250        };
251        let argv = self.render(&values)?;
252        let opts = ExecOpts::new().cwd(cwd).timeout_secs(self.spec.timeout);
253        let stdout = proc::run(&argv, &opts)?;
254        self.extract(&stdout)
255    }
256
257    /// Structured output through the CLI's own mechanism when the template
258    /// exposes one, otherwise by asking for JSON in the prompt and parsing it
259    /// back out.
260    pub fn ask_json<T: serde::de::DeserializeOwned>(
261        &self,
262        prompt: &str,
263        schema: &Value,
264        cwd: &Path,
265        effort: Option<&str>,
266    ) -> Result<T> {
267        let text = if self.supports_schema() {
268            let file = TempJson::write(schema)?;
269            self.ask_inner(prompt, cwd, effort, Some(file.path()))?
270        } else {
271            let full = format!(
272                "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
273                serde_json::to_string_pretty(schema).unwrap_or_default()
274            );
275            self.ask_inner(&full, cwd, effort, None)?
276        };
277        jsonx::extract_into(&text).map_err(|e| {
278            spar_err!(
279                "agent '{}' returned an unusable answer: {e}",
280                self.spec.name
281            )
282        })
283    }
284
285    /// Review the branch against `base`.
286    ///
287    /// Deliberately generic. `codex exec review` was tried and rejected: it
288    /// refuses a custom prompt alongside `--base` and returns prose regardless
289    /// of `--output-schema`, so it cannot yield a machine checkable verdict.
290    /// Running inside the worktree is what makes an agent repo aware, not a
291    /// subcommand.
292    pub fn review<T: serde::de::DeserializeOwned>(
293        &self,
294        base: &str,
295        prompt: &str,
296        schema: &Value,
297        cwd: &Path,
298        effort: Option<&str>,
299    ) -> Result<T> {
300        let scoped = format!(
301            "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
302             working directory. Inspect them with git, then read the surrounding code before \
303             judging. Do not review only the diff."
304        );
305        self.ask_json(&scoped, schema, cwd, effort)
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Placeholders
311// ---------------------------------------------------------------------------
312
313#[derive(Debug, Default, Clone)]
314pub struct Placeholders {
315    pub prompt: Option<String>,
316    pub system: Option<String>,
317    pub model: Option<String>,
318    pub effort: Option<String>,
319    pub cwd: Option<String>,
320    pub schema_file: Option<String>,
321}
322
323impl Placeholders {
324    fn get(&self, key: &str) -> Option<&str> {
325        let value = match key {
326            "prompt" => self.prompt.as_deref(),
327            "system" => self.system.as_deref(),
328            "model" => self.model.as_deref(),
329            "effort" => self.effort.as_deref(),
330            "cwd" => self.cwd.as_deref(),
331            "schema_file" => self.schema_file.as_deref(),
332            _ => None,
333        };
334        value.filter(|v| !v.is_empty())
335    }
336
337    /// Substitute every placeholder in one argument. `None` means a placeholder
338    /// in this argument had no value, so the whole group is dropped.
339    fn substitute(&self, arg: &str) -> Option<String> {
340        const KEYS: [&str; 6] = ["prompt", "system", "model", "effort", "cwd", "schema_file"];
341        let mut out = arg.to_string();
342        for key in KEYS {
343            let token = format!("{{{key}}}");
344            if out.contains(&token) {
345                let value = self.get(key)?;
346                out = out.replace(&token, value);
347            }
348        }
349        Some(out)
350    }
351}
352
353// ---------------------------------------------------------------------------
354// JSONL helpers
355// ---------------------------------------------------------------------------
356
357/// Follow a dotted path, returning None if any hop is missing.
358fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
359    if path.is_empty() {
360        return None;
361    }
362    let mut node = value;
363    for part in path.split('.') {
364        node = node.as_object()?.get(part)?;
365    }
366    Some(node)
367}
368
369fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
370    if wanted.is_empty() {
371        return false;
372    }
373    wanted
374        .iter()
375        .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
376}
377
378fn as_text(value: &Value) -> Option<String> {
379    match value {
380        Value::String(s) => Some(s.clone()),
381        Value::Null => None,
382        other => Some(other.to_string()),
383    }
384}
385
386fn truncate(text: &str, max: usize) -> String {
387    text.chars().take(max).collect()
388}
389
390// ---------------------------------------------------------------------------
391// Temporary schema file
392// ---------------------------------------------------------------------------
393
394/// A schema written somewhere the CLI can read it, removed when it goes out of
395/// scope even if the agent call fails.
396struct TempJson {
397    path: PathBuf,
398}
399
400impl TempJson {
401    fn write(value: &Value) -> Result<Self> {
402        use std::sync::atomic::{AtomicU64, Ordering};
403        static COUNTER: AtomicU64 = AtomicU64::new(0);
404
405        let nanos = std::time::SystemTime::now()
406            .duration_since(std::time::UNIX_EPOCH)
407            .map(|d| d.as_nanos())
408            .unwrap_or(0);
409        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
410        let path = std::env::temp_dir().join(format!(
411            "spar-schema-{}-{nanos}-{unique}.json",
412            std::process::id()
413        ));
414        std::fs::write(&path, serde_json::to_vec_pretty(value)?)
415            .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
416        Ok(Self { path })
417    }
418
419    fn path(&self) -> &Path {
420        &self.path
421    }
422}
423
424impl Drop for TempJson {
425    fn drop(&mut self) {
426        let _ = std::fs::remove_file(&self.path);
427    }
428}
429
430// ---------------------------------------------------------------------------
431// Correlation
432// ---------------------------------------------------------------------------
433
434/// Whether two paths name the same executable.
435///
436/// Comparing the raw strings misses aliases: a symlink or a hard link points at
437/// the same binary under a different path, which would let two agents run the
438/// identical CLI without tripping the warning below. Device and inode see
439/// through both.
440fn same_executable(a: &Path, b: &Path) -> bool {
441    #[cfg(unix)]
442    {
443        use std::os::unix::fs::MetadataExt;
444        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
445            return x.dev() == y.dev() && x.ino() == y.ino();
446        }
447    }
448    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
449        (Ok(x), Ok(y)) => x == y,
450        _ => a == b,
451    }
452}
453
454/// Two agents are only an independent review if they can actually disagree.
455///
456/// Config keys are arbitrary, so `alpha` and `beta` can both be Claude on the
457/// same model. Compare what actually runs: the resolved binary and the
458/// configured model, never the names.
459pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
460    for i in 0..agents.len() {
461        for j in (i + 1)..agents.len() {
462            let (a, b) = (&agents[i], &agents[j]);
463            let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
464                continue;
465            };
466            if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
467                continue;
468            }
469            let model = if a.spec.model_key().is_empty() {
470                "the CLI's default".to_string()
471            } else {
472                a.spec.model_key()
473            };
474            let where_at = if pa == pb {
475                pa.display().to_string()
476            } else {
477                format!(
478                    "the same executable ({} and {} are the same file)",
479                    pa.display(),
480                    pb.display()
481                )
482            };
483            return Some(format!(
484                "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
485                 findings will be correlated: the same model reviewing itself shares the blind \
486                 spots of the model that wrote the code, so it is far less likely to catch what \
487                 the implementer missed. That produces an approval indistinguishable from a real \
488                 review, which is worse than no review at all. Give the two agents different \
489                 CLIs or different models.",
490                a.name(),
491                b.name()
492            ));
493        }
494    }
495    None
496}
497
498/// Build every configured agent, resolving each binary up front so a missing
499/// CLI fails before any model is billed.
500pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
501    let agents: Vec<Agent> = cfg.agents.iter().cloned().map(Agent::new).collect();
502    for agent in &agents {
503        agent.resolve_bin()?;
504    }
505    Ok(agents)
506}
507
508/// Look an agent up by name in a built list.
509pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
510    agents.iter().find(|a| a.name() == name).ok_or_else(|| {
511        SparError::new(format!(
512            "no agent named '{name}' ({})",
513            agents
514                .iter()
515                .map(Agent::name)
516                .collect::<Vec<_>>()
517                .join(", ")
518        ))
519    })
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::config::{OutputMode, SystemVia};
526
527    fn spec(command: Vec<CommandPart>) -> AgentSpec {
528        AgentSpec {
529            name: "test".into(),
530            command,
531            model: None,
532            effort: None,
533            output: OutputMode::Text,
534            message_match: BTreeMap::new(),
535            message_path: None,
536            search_paths: vec![],
537            system_via: SystemVia::Prompt,
538            timeout: 60,
539            models: vec![],
540            efforts: vec![],
541            options_note: None,
542        }
543    }
544
545    fn one(s: &str) -> CommandPart {
546        CommandPart::One(s.into())
547    }
548
549    fn group(parts: &[&str]) -> CommandPart {
550        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
551    }
552
553    fn agent(command: Vec<CommandPart>) -> Agent {
554        Agent::with_bin(spec(command), "/fake/bin")
555    }
556
557    fn values() -> Placeholders {
558        Placeholders {
559            prompt: Some("hi".into()),
560            ..Default::default()
561        }
562    }
563
564    // -- rendering -------------------------------------------------------
565
566    #[test]
567    fn placeholders_are_substituted() {
568        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
569        let v = Placeholders {
570            model: Some("m1".into()),
571            ..values()
572        };
573        assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
574    }
575
576    #[test]
577    fn an_unset_placeholder_drops_the_whole_group() {
578        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
579        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
580    }
581
582    #[test]
583    fn an_empty_string_drops_the_group_too() {
584        let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
585        let v = Placeholders {
586            effort: Some(String::new()),
587            ..values()
588        };
589        assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
590    }
591
592    #[test]
593    fn a_bare_arg_with_an_unset_placeholder_drops() {
594        let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
595        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
596    }
597
598    #[test]
599    fn literal_args_survive() {
600        let a = agent(vec![
601            one("x"),
602            one("exec"),
603            one("--json"),
604            one("--"),
605            one("{prompt}"),
606        ]);
607        assert_eq!(
608            vec!["/fake/bin", "exec", "--json", "--", "hi"],
609            a.render(&values()).unwrap()
610        );
611    }
612
613    #[test]
614    fn an_embedded_placeholder_substitutes_in_place() {
615        let a = agent(vec![
616            one("x"),
617            group(&["-c", "model_reasoning_effort={effort}"]),
618        ]);
619        let v = Placeholders {
620            effort: Some("ultra".into()),
621            ..Default::default()
622        };
623        assert_eq!(
624            vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
625            a.render(&v).unwrap()
626        );
627    }
628
629    #[test]
630    fn a_group_with_two_placeholders_needs_both() {
631        let a = agent(vec![
632            one("x"),
633            group(&["--a", "{model}", "--b", "{effort}"]),
634        ]);
635        let v = Placeholders {
636            model: Some("m".into()),
637            ..Default::default()
638        };
639        assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
640    }
641
642    #[test]
643    fn supports_schema_detects_the_placeholder() {
644        assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
645        assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
646    }
647
648    // -- output adapters -------------------------------------------------
649
650    #[test]
651    fn text_passes_through_trimmed() {
652        assert_eq!("hello", agent(vec![one("x")]).extract("  hello\n").unwrap());
653    }
654
655    #[test]
656    fn jsonl_picks_the_matching_event() {
657        let mut spec = spec(vec![one("x")]);
658        spec.output = OutputMode::Jsonl;
659        spec.message_path = Some("item.text".into());
660        spec.message_match = BTreeMap::from([
661            ("type".to_string(), "item.completed".to_string()),
662            ("item.type".to_string(), "agent_message".to_string()),
663        ]);
664        let a = Agent::with_bin(spec, "/fake/bin");
665        let stream = [
666            r#"{"type":"thread.started","thread_id":"t1"}"#,
667            r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
668            r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
669            "not json at all",
670        ]
671        .join("\n");
672        assert_eq!("the answer", a.extract(&stream).unwrap());
673    }
674
675    #[test]
676    fn jsonl_raises_on_an_error_with_no_message() {
677        let mut spec = spec(vec![one("x")]);
678        spec.output = OutputMode::Jsonl;
679        spec.message_path = Some("item.text".into());
680        spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
681        let a = Agent::with_bin(spec, "/fake/bin");
682        assert!(a
683            .extract(r#"{"type":"turn.failed","error":"boom"}"#)
684            .is_err());
685    }
686
687    #[test]
688    fn jsonl_joins_several_agent_messages() {
689        let mut spec = spec(vec![one("x")]);
690        spec.output = OutputMode::Jsonl;
691        spec.message_path = Some("text".into());
692        spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
693        let a = Agent::with_bin(spec, "/fake/bin");
694        let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
695        assert_eq!("one\ntwo", a.extract(stream).unwrap());
696    }
697
698    #[test]
699    fn dig_walks_a_dotted_path() {
700        let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
701        assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
702        assert_eq!(None, dig(&v, "a.b.missing"));
703        assert_eq!(None, dig(&v, ""));
704    }
705
706    // -- binary resolution -----------------------------------------------
707
708    #[test]
709    fn a_missing_binary_lists_everywhere_it_looked() {
710        let mut s = spec(vec![one("definitely-not-installed-xyz")]);
711        s.search_paths = vec!["/nowhere/at/all".into()];
712        s.name = "codex".into();
713        let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
714        assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
715        assert!(
716            err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
717            "{err}"
718        );
719        assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
720    }
721
722    #[test]
723    fn a_search_path_that_already_names_the_binary_is_used_as_is() {
724        let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
725        std::fs::create_dir_all(&dir).unwrap();
726        let bin = dir.join("mytool");
727        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
728        #[cfg(unix)]
729        {
730            use std::os::unix::fs::PermissionsExt;
731            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
732        }
733        let mut s = spec(vec![one("mytool")]);
734        s.search_paths = vec![bin.display().to_string()];
735        assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
736        let _ = std::fs::remove_dir_all(&dir);
737    }
738
739    // -- correlation -----------------------------------------------------
740
741    fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
742        let mut s = spec(vec![one("prog")]);
743        s.name = name.into();
744        s.model = model.map(str::to_string);
745        Agent::with_bin(s, bin)
746    }
747
748    #[test]
749    fn same_bin_same_model_warns() {
750        let agents = vec![
751            named("alpha", "/usr/local/bin/claude", Some("fable")),
752            named("beta", "/usr/local/bin/claude", Some("fable")),
753        ];
754        let msg = correlation_warning(&agents).expect("should warn");
755        assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
756    }
757
758    #[test]
759    fn different_model_does_not_warn() {
760        let agents = vec![
761            named("a", "/usr/local/bin/claude", Some("fable")),
762            named("b", "/usr/local/bin/claude", Some("opus")),
763        ];
764        assert!(correlation_warning(&agents).is_none());
765    }
766
767    #[test]
768    fn different_bin_does_not_warn() {
769        let agents = vec![
770            named("a", "/usr/local/bin/claude", Some("fable")),
771            named("b", "/usr/local/bin/codex", Some("fable")),
772        ];
773        assert!(correlation_warning(&agents).is_none());
774    }
775
776    #[test]
777    fn unset_and_empty_model_both_mean_the_default_and_warn() {
778        let agents = vec![
779            named("a", "/usr/local/bin/claude", None),
780            named("b", "/usr/local/bin/claude", Some("")),
781        ];
782        let msg = correlation_warning(&agents).expect("should warn");
783        assert!(msg.contains("the CLI's default"), "{msg}");
784    }
785
786    #[test]
787    fn a_padded_model_still_warns() {
788        let agents = vec![
789            named("a", "/usr/local/bin/claude", Some("fable")),
790            named("b", "/usr/local/bin/claude", Some(" fable ")),
791        ];
792        assert!(correlation_warning(&agents).is_some());
793    }
794
795    #[test]
796    fn an_empty_model_against_a_named_one_does_not_warn() {
797        let agents = vec![
798            named("a", "/usr/local/bin/claude", Some("")),
799            named("b", "/usr/local/bin/claude", Some("fable")),
800        ];
801        assert!(correlation_warning(&agents).is_none());
802    }
803
804    #[cfg(unix)]
805    #[test]
806    fn a_symlinked_binary_warns_and_names_both_paths() {
807        use std::os::unix::fs::PermissionsExt;
808        let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
809        let _ = std::fs::remove_dir_all(&dir);
810        std::fs::create_dir_all(&dir).unwrap();
811        let real = dir.join("claude");
812        let link = dir.join("claude-alias");
813        std::fs::write(&real, "#!/bin/sh\n").unwrap();
814        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
815        std::os::unix::fs::symlink(&real, &link).unwrap();
816
817        let agents = vec![
818            named("alpha", real.to_str().unwrap(), Some("fable")),
819            named("beta", link.to_str().unwrap(), Some("fable")),
820        ];
821        let msg = correlation_warning(&agents).expect("should warn");
822        assert!(msg.contains(real.to_str().unwrap()), "{msg}");
823        assert!(msg.contains(link.to_str().unwrap()), "{msg}");
824        let _ = std::fs::remove_dir_all(&dir);
825    }
826
827    #[cfg(unix)]
828    #[test]
829    fn two_distinct_real_binaries_stay_quiet() {
830        use std::os::unix::fs::PermissionsExt;
831        let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
832        let _ = std::fs::remove_dir_all(&dir);
833        std::fs::create_dir_all(&dir).unwrap();
834        let mut paths = Vec::new();
835        for name in ["claude", "codex"] {
836            let path = dir.join(name);
837            std::fs::write(&path, "#!/bin/sh\n").unwrap();
838            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
839            paths.push(path);
840        }
841        let agents = vec![
842            named("a", paths[0].to_str().unwrap(), Some("fable")),
843            named("b", paths[1].to_str().unwrap(), Some("fable")),
844        ];
845        assert!(correlation_warning(&agents).is_none());
846        let _ = std::fs::remove_dir_all(&dir);
847    }
848
849    #[test]
850    fn the_style_rules_ask_for_brevity_and_no_attribution() {
851        let lower = STYLE_RULES.to_lowercase();
852        assert!(lower.contains("brief"));
853        assert!(lower.contains("co-authored-by"));
854        assert!(lower.contains("em-dash"));
855    }
856}