1use 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
20pub 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 #[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 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 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 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 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 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 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 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#[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 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
353fn 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
390struct 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
430fn 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
454pub 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
498pub 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
508pub 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 #[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 #[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 #[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 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}