Skip to main content

speccoder_runtime/
copy.rs

1//! What went wrong, as one block of plain text to paste into an issue or a
2//! chat: the code and the message, each fix line, the docs link, and
3//! speccoder's version, the OS and the run — never a secret. The app
4//! copies the same block (its twin in `ui/src/lib/copy.ts`); the CLI's
5//! `--copy` puts it on the system clipboard.
6
7use std::io::Write as _;
8use std::process::{Command, Stdio};
9
10use speccoder_core::CommandResult;
11
12/// The block for `result`; `run` is the run it happened in, if any.
13#[must_use]
14pub fn block(result: &CommandResult, run: Option<&str>) -> String {
15    let mut out = match result.code() {
16        Some(code) => format!(
17            "{}[{}]: {}\n",
18            code.kind.prefix(),
19            code.name,
20            result.message()
21        ),
22        None => format!("{}\n", result.message()),
23    };
24    for f in result.fix() {
25        out.push_str(&format!("fix: {}\n", f.trim_start_matches("→ ")));
26    }
27    if let Some(code) = result.code() {
28        out.push_str(&format!("docs: {}\n", code.docs()));
29    }
30    out.push_str(&format!(
31        "speccoder {} · {} {}",
32        env!("CARGO_PKG_VERSION"),
33        std::env::consts::OS,
34        std::env::consts::ARCH
35    ));
36    if let Some(run) = run.map(str::to_owned).or_else(|| run_of(result)) {
37        out.push_str(&format!(" · run {run}"));
38    }
39    out.push('\n');
40    crate::doctor::redact(&out)
41}
42
43/// The run a result's payload names (`data.run_id`).
44fn run_of(result: &CommandResult) -> Option<String> {
45    let v = serde_json::to_value(result).ok()?;
46    v["data"]["run_id"]
47        .as_str()
48        .or_else(|| v["data"]["run"].as_str())
49        .filter(|r| !r.is_empty())
50        .map(str::to_owned)
51}
52
53/// The clipboard programs tried, in order.
54const CLIPBOARDS: &[(&str, &[&str])] = &[
55    ("pbcopy", &[]),
56    ("wl-copy", &[]),
57    ("xclip", &["-selection", "clipboard"]),
58];
59
60/// Puts `text` on the system clipboard; the program that took it.
61///
62/// # Errors
63///
64/// No clipboard program is installed, or none took the text.
65pub fn to_clipboard(text: &str) -> Result<&'static str, String> {
66    for (program, args) in CLIPBOARDS {
67        let Ok(mut child) = Command::new(program)
68            .args(*args)
69            .stdin(Stdio::piped())
70            .stdout(Stdio::null())
71            .stderr(Stdio::null())
72            .spawn()
73        else {
74            continue;
75        };
76        let wrote = child
77            .stdin
78            .take()
79            .is_some_and(|mut stdin| stdin.write_all(text.as_bytes()).is_ok());
80        if wrote && child.wait().is_ok_and(|s| s.success()) {
81            return Ok(program);
82        }
83    }
84    Err("no clipboard program took it (pbcopy, wl-copy or xclip)".to_owned())
85}
86
87#[cfg(test)]
88mod tests {
89    use speccoder_core::CommandStatus;
90
91    use super::*;
92
93    #[test]
94    fn the_block_names_the_code_its_fixes_and_where_it_happened() {
95        let r = CommandResult::new("run", CommandStatus::ConfigError, "no API key")
96            .with_code("KEY-MISSING")
97            .with_fix(vec![
98                "export ANTHROPIC_API_KEY=…".to_owned(),
99                "→ or set it in Settings".to_owned(),
100            ]);
101        let b = block(&r, Some("r-42"));
102        let lines: Vec<&str> = b.lines().collect();
103        assert_eq!(lines[0], "error[KEY-MISSING]: no API key");
104        assert_eq!(lines[1], "fix: export ANTHROPIC_API_KEY=…");
105        assert_eq!(lines[2], "fix: or set it in Settings");
106        assert!(lines[3].starts_with("docs: https://"), "{b}");
107        assert!(
108            lines[4].starts_with(&format!("speccoder {}", env!("CARGO_PKG_VERSION")))
109                && lines[4].ends_with(" · run r-42"),
110            "{b}"
111        );
112    }
113
114    #[test]
115    fn a_masked_value_stays_masked() {
116        crate::secrets::register("COPY_TEST_TOKEN", "copy-block-Zq9 value");
117        let message = crate::secrets::mask("the provider said: copy-block-Zq9 value is wrong");
118        let r = CommandResult::new("run", CommandStatus::RunFailed, &message)
119            .with_fix(vec!["sk-ant-api03-abcdefghijklmnopq".to_owned()]);
120        let b = block(&r, None);
121        assert!(!b.contains("copy-block-Zq9"), "{b}");
122        assert!(b.contains("[redacted:COPY_TEST_TOKEN]"), "{b}");
123        assert!(!b.contains("abcdefghijklmnopq"), "{b}");
124    }
125}