diff --git a/shell/src/code/functions/context.rs b/shell/src/code/functions/context.rs new file mode 100644 index 000000000..5fbe74cae --- /dev/null +++ b/shell/src/code/functions/context.rs @@ -0,0 +1,198 @@ +//! `coder::context` — one-call workspace snapshot for seeding a coding +//! session's environment block: effective allowed roots, platform, bounded +//! git state, and repo instruction files (AGENTS.md / CLAUDE.md). Read-only. +//! Git state comes from running the `git` binary with the primary root as +//! cwd; a missing binary, a timeout, or a non-repo root yields `git: null` +//! — never an error, so callers can always render whatever came back. + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::code::config::CoderConfig; +use crate::code::path::PathResolver; + +/// Maximum porcelain status entries reported before truncation. +const STATUS_MAX_ENTRIES: usize = 50; +/// Number of recent commits reported (`git log --oneline`). +const RECENT_COMMITS: usize = 5; +/// Per-file byte cap for instruction-file content. +const INSTRUCTION_MAX_BYTES: usize = 16 * 1024; +/// Instruction files probed at the primary root, in report order. +const INSTRUCTION_FILE_NAMES: [&str; 2] = ["AGENTS.md", "CLAUDE.md"]; +/// Wall-clock budget per git invocation. +const GIT_TIMEOUT: Duration = Duration::from_secs(2); + +/// No caller-visible arguments — `coder::context` is a pure discovery call. +// examples are wire-contract; goldens pin them. +#[derive(Debug, Default, Deserialize, JsonSchema)] +#[schemars(example = "example_context_input")] +pub struct ContextInput { + /// Internal harness filesystem scope; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub fs_scope: Option, +} + +// examples are wire-contract; goldens pin them. +fn example_context_input() -> serde_json::Value { + serde_json::json!({}) +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ContextOutput { + /// The primary allowed root — relative paths in every `coder::*` call + /// resolve against this directory. + pub primary_root: String, + /// Canonical absolute paths of all allowed roots, primary first. + pub base_paths: Vec, + pub platform: Platform, + /// Git state of the primary root; `null` when the root is not inside a + /// git work tree or the `git` binary is unavailable. + #[serde(skip_serializing_if = "Option::is_none")] + pub git: Option, + /// Repo instruction files (AGENTS.md, CLAUDE.md) found at the primary + /// root, in probe order. Content is capped per file; `truncated` marks + /// a capped entry. + pub instruction_files: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct Platform { + /// Operating system (`std::env::consts::OS`, e.g. "macos", "linux"). + pub os: String, + /// CPU architecture (`std::env::consts::ARCH`, e.g. "aarch64"). + pub arch: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct GitContext { + /// Current branch name; `"HEAD"` when detached. + pub branch: String, + /// `git status --porcelain` lines, capped at 50 entries. + pub status: Vec, + /// True when the status list was capped. + pub status_truncated: bool, + /// Up to the last 5 commits, `git log --oneline` format. + pub recent_commits: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct InstructionFile { + /// Root-relative file name (e.g. "AGENTS.md"). + pub path: String, + /// File content, capped at 16 KiB (char-boundary safe). + pub content: String, + /// True when `content` was capped. + pub truncated: bool, +} + +pub async fn handle( + resolver: Arc, + _cfg: Arc, + _req: ContextInput, +) -> Result { + let primary = resolver.base_root().to_path_buf(); + let git = git_context(&primary).await; + let resolver_for_files = resolver.clone(); + let instruction_files = + tokio::task::spawn_blocking(move || read_instruction_files(&resolver_for_files)) + .await + .map_err(|e| format!("context task join failed: {e}"))?; + + Ok(ContextOutput { + primary_root: primary.display().to_string(), + base_paths: resolver + .roots() + .iter() + .map(|p| p.display().to_string()) + .collect(), + platform: Platform { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + }, + git, + instruction_files, + }) +} + +/// Run one bounded git command; `None` on spawn failure, timeout, or +/// non-zero exit (all equivalent to "no git state" for this snapshot). +async fn git_lines(root: &Path, args: &[&str]) -> Option> { + let fut = tokio::process::Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .stdin(std::process::Stdio::null()) + .output(); + let out = tokio::time::timeout(GIT_TIMEOUT, fut).await.ok()?.ok()?; + if !out.status.success() { + return None; + } + Some( + String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect(), + ) +} + +async fn git_context(root: &Path) -> Option { + // rev-parse doubles as the is-a-repo probe: any failure → not a repo + // (or no usable git), and the whole block is omitted. + let branch = git_lines(root, &["rev-parse", "--abbrev-ref", "HEAD"]) + .await? + .into_iter() + .next()?; + let mut status = git_lines(root, &["status", "--porcelain"]) + .await + .unwrap_or_default(); + let status_truncated = status.len() > STATUS_MAX_ENTRIES; + status.truncate(STATUS_MAX_ENTRIES); + // Fails on a repo with no commits yet — an empty history, not an error. + let recent_commits = git_lines(root, &["log", "--oneline", &format!("-{RECENT_COMMITS}")]) + .await + .unwrap_or_default(); + Some(GitContext { + branch, + status, + status_truncated, + recent_commits, + }) +} + +fn read_instruction_files(resolver: &PathResolver) -> Vec { + let mut out = Vec::new(); + for name in INSTRUCTION_FILE_NAMES { + // Resolve through the jail so non-accessible globs keep applying. + let Ok(abs) = resolver.resolve(name) else { + continue; + }; + if resolver.is_non_accessible(&abs) { + continue; + } + let Ok(bytes) = std::fs::read(&abs) else { + continue; + }; + let full = String::from_utf8_lossy(&bytes); + let truncated = full.len() > INSTRUCTION_MAX_BYTES; + let content = if truncated { + let mut end = INSTRUCTION_MAX_BYTES; + while !full.is_char_boundary(end) { + end -= 1; + } + full[..end].to_string() + } else { + full.into_owned() + }; + out.push(InstructionFile { + path: name.to_string(), + content, + truncated, + }); + } + out +} diff --git a/shell/src/code/functions/mod.rs b/shell/src/code/functions/mod.rs index 4e4971213..b49cd68f1 100644 --- a/shell/src/code/functions/mod.rs +++ b/shell/src/code/functions/mod.rs @@ -10,6 +10,7 @@ //! `tests/code_golden_schemas.rs` snapshots each entry so ANY change to the //! agent-facing wire surface shows up as an explicit, reviewed diff. +pub mod context; pub mod create_file; pub mod delete_file; pub mod info; @@ -47,6 +48,15 @@ const INFO_DESC: &str = "Report the coder jail: canonical allowed roots (primary path was rejected — paths outside every allowed root need the \ shell worker's shell::fs::* instead."; +const CONTEXT_ID: &str = "coder::context"; +const CONTEXT_DESC: &str = "One-call workspace snapshot for seeding a coding session: the \ + allowed roots (primary first), platform os/arch, bounded git state \ + of the primary root (current branch, porcelain status capped at 50 \ + entries, last 5 commits), and repo instruction files (AGENTS.md / \ + CLAUDE.md at the primary root, capped at 16 KiB each). Read-only \ + and never fails on a non-repo: `git` is null when the root is not \ + a git work tree or the git binary is unavailable."; + const READ_FILE_ID: &str = "coder::read-file"; const READ_FILE_DESC: &str = "Read a file window-first: probe with stat: true (size/mtime/mode \ plus total_lines, no content), then fetch just the lines you need \ @@ -208,6 +218,7 @@ where pub fn catalog() -> Vec { vec![ spec::(INFO_ID, INFO_DESC), + spec::(CONTEXT_ID, CONTEXT_DESC), spec::(READ_FILE_ID, READ_FILE_DESC), spec::(SEARCH_ID, SEARCH_DESC), spec::( @@ -240,6 +251,8 @@ pub fn register_all(iii: &IIIClient, cells: CodeCells) { let mut registered: usize = 0; register_info(iii, cells.clone()); registered += 1; + register_context(iii, cells.clone()); + registered += 1; register_read_file(iii, cells.clone()); registered += 1; register_search(iii, cells.clone()); @@ -281,6 +294,27 @@ fn register_info(iii: &IIIClient, cells: CodeCells) { ); } +fn register_context(iii: &IIIClient, cells: CodeCells) { + iii.register_function( + CONTEXT_ID, + RegisterFunction::new_async(move |req: context::ContextInput| { + let cells = cells.clone(); + async move { + let resolver = cells.resolver.read().await.clone(); + let resolver = resolver.session_scoped( + crate::fs::scope_root(req.fs_scope.as_ref()), + crate::fs::scope_grants(req.fs_scope.as_ref()), + ); + let cfg = cells.config.read().await.clone(); + context::handle(resolver, cfg, req) + .await + .map_err(Error::from) + } + }) + .description(CONTEXT_DESC), + ); +} + fn register_read_file(iii: &IIIClient, cells: CodeCells) { iii.register_function( READ_FILE_ID, diff --git a/shell/src/code/functions/update_file.rs b/shell/src/code/functions/update_file.rs index 0195f452f..35f1d3ccc 100644 --- a/shell/src/code/functions/update_file.rs +++ b/shell/src/code/functions/update_file.rs @@ -1,7 +1,8 @@ -//! `coder::update-file` — batched line-oriented and regex edits across one +//! `coder::update-file` — batched line-oriented and content edits across one //! or more files. Line ops are applied **bottom-up** so earlier ops see the -//! original line numbers; regex replaces run afterward on the serialized -//! body. Each file commits atomically via sibling temp + `rename`. +//! original line numbers; content ops (`str_replace`, regex `replace`) run +//! afterward on the serialized body, in array order. Each file commits +//! atomically via sibling temp + `rename`. //! //! Line ops (1-based, inclusive): //! @@ -11,8 +12,14 @@ //! - `{ op: "update_lines", from_line: A, to_line: B, content: "..." }` — //! overwrite lines A..=B with `content` (split by `\n`). //! -//! Content op: +//! Content ops: //! +//! - `{ op: "str_replace", old_str: "...", new_str: "...", replace_all?: bool }` +//! — replace one exact literal occurrence of `old_str` (no regex). The +//! preferred op for targeted edits: without `replace_all` the op fails +//! (C210, nothing written) unless `old_str` occurs exactly once; with +//! `replace_all: true` every occurrence is replaced (at least one +//! required). //! - `{ op: "replace", pattern: "...", replacement: "...", ignore_case?: bool, //! dot_matches_newline?: bool, expect_matches?: u64 }` — regex substitution //! on the full file body after line ops. `dot_matches_newline` lets `.` @@ -67,6 +74,24 @@ pub enum UpdateOp { to_line: u32, content: String, }, + /// Replace one exact literal occurrence of `old_str` with `new_str` — + /// THE PREFERRED OP for targeted edits. Copy `old_str` verbatim from + /// the file (read it first), including whitespace and indentation; no + /// regex, no escaping. Fails with C210 (nothing written) when + /// `old_str` is not found, or occurs more than once without + /// `replace_all` — include more surrounding lines in `old_str` to + /// make it unique. + #[serde(rename = "str_replace")] + StrReplace { + /// Exact existing text to replace, copied verbatim from the file. + old_str: String, + /// Replacement text (may be empty to delete `old_str`). + new_str: String, + /// Replace EVERY occurrence instead of requiring exactly one. + /// At least one occurrence is still required. + #[serde(default)] + replace_all: bool, + }, /// Replace all regex matches in the file body (after line ops). Replace { pattern: String, @@ -112,6 +137,8 @@ fn example_update_file_input() -> serde_json::Value { "files": [{ "path": "src/lib.rs", "ops": [ + { "op": "str_replace", "old_str": "fn hello() -> &'static str {\n \"hallo\"\n}", + "new_str": "fn hello() -> &'static str {\n \"hello\"\n}" }, { "op": "insert", "at_line": 1, "content": "// generated by coder\n" }, { "op": "update_lines", "from_line": 5, "to_line": 7, "content": "pub fn hello() {\n println!(\"hello\");\n}\n" }, @@ -454,7 +481,7 @@ fn record_line_op_events( let consumed = *to_line as i64 - a + 1; (a, m - consumed, a, a + m - 1) } - UpdateOp::Replace { .. } => unreachable!("line ops only"), + UpdateOp::Replace { .. } | UpdateOp::StrReplace { .. } => unreachable!("line ops only"), }; if delta != 0 { events.push(MutationEvent { pos, delta }); @@ -471,11 +498,28 @@ fn record_line_op_events( } } -/// Apply every `replace` op in `ops` (in array order), each on the body -/// produced by all earlier ops, recording mutation events and up to -/// `ECHO_MAX_SITES` echo anchors per op. Match positions are located on -/// each op's OWN input body; earlier replacements within the same op shift -/// later sites through the event list like any other mutation. +/// Post-apply match-count rule for one content op: regex `replace` enforces +/// its optional `expect_matches`; `str_replace` enforces found-and-unique +/// (or found-at-all with `replace_all`). +enum CountRule<'a> { + ExpectMatches { + pattern: &'a str, + expect: Option, + }, + Unique { + old_str: &'a str, + replace_all: bool, + }, +} + +/// Apply every content op in `ops` (`str_replace` / regex `replace`, in +/// array order), each on the body produced by all earlier ops, recording +/// mutation events and up to `ECHO_MAX_SITES` echo anchors per op. Match +/// positions are located on each op's OWN input body; earlier replacements +/// within the same op shift later sites through the event list like any +/// other mutation. A `str_replace` desugars into the same machinery: an +/// escaped-literal regex plus a `$$`-escaped replacement, so expansion is +/// verbatim. fn apply_regex_ops( mut bytes: Vec, ops: &[UpdateOp], @@ -483,33 +527,71 @@ fn apply_regex_ops( anchors: &mut Vec, ) -> Result, CoderError> { for (op_index, op) in ops.iter().enumerate() { - let UpdateOp::Replace { - pattern, - replacement, - ignore_case, - dot_matches_newline, - expect_matches, - } = op - else { - continue; - }; - if pattern.is_empty() { - return Err(CoderError::BadInput( - "replace.pattern must not be empty".into(), - )); - } - let mut builder = regex::RegexBuilder::new(pattern); - builder.case_insensitive(*ignore_case); - builder.dot_matches_new_line(*dot_matches_newline); - let re = builder - .build() - .map_err(|e| CoderError::BadInput(format!("bad regex {pattern:?}: {e}")))?; - // R1 (v0.4.1): pre-write guard — every `$` capture reference in - // the replacement must name a group the pattern actually defines. - // Runs right after compilation, before ANY replacement work, so - // an undefined reference fails the entry (C210) with the file - // byte-identical on disk. - validate_replacement_refs(&re, pattern, replacement)?; + let (re, replacement, count_rule): (regex::Regex, std::borrow::Cow<'_, str>, CountRule) = + match op { + UpdateOp::Replace { + pattern, + replacement, + ignore_case, + dot_matches_newline, + expect_matches, + } => { + if pattern.is_empty() { + return Err(CoderError::BadInput( + "replace.pattern must not be empty".into(), + )); + } + let mut builder = regex::RegexBuilder::new(pattern); + builder.case_insensitive(*ignore_case); + builder.dot_matches_new_line(*dot_matches_newline); + let re = builder + .build() + .map_err(|e| CoderError::BadInput(format!("bad regex {pattern:?}: {e}")))?; + // R1 (v0.4.1): pre-write guard — every `$` capture reference in + // the replacement must name a group the pattern actually defines. + // Runs right after compilation, before ANY replacement work, so + // an undefined reference fails the entry (C210) with the file + // byte-identical on disk. + validate_replacement_refs(&re, pattern, replacement)?; + ( + re, + std::borrow::Cow::Borrowed(replacement.as_str()), + CountRule::ExpectMatches { + pattern, + expect: *expect_matches, + }, + ) + } + UpdateOp::StrReplace { + old_str, + new_str, + replace_all, + } => { + if old_str.is_empty() { + return Err(CoderError::BadInput( + "str_replace.old_str must not be empty".into(), + )); + } + let re = regex::RegexBuilder::new(®ex::escape(old_str)) + .build() + .map_err(|e| { + CoderError::BadInput(format!("str_replace.old_str too large: {e}")) + })?; + // `$$` so `Captures::expand` emits `new_str` verbatim + // (a bare `$` would be an — always undefined — capture + // reference against the escaped-literal pattern). + ( + re, + std::borrow::Cow::Owned(new_str.replace('$', "$$")), + CountRule::Unique { + old_str, + replace_all: *replace_all, + }, + ) + } + _ => continue, + }; + let replacement: &str = &replacement; let op_events_start = events.len(); // (site first/last line in its own frame, events.len() at @@ -548,7 +630,7 @@ fn apply_regex_ops( expanded }) .into_owned(); - // expect_matches guard: validated BEFORE any filesystem mutation — + // Match-count guard: validated BEFORE any filesystem mutation — // `try_update_one` only reaches `atomic_write` after every op in // the file's pipeline succeeded, so erroring here leaves the file // byte-identical on disk (earlier ops' changes are in-memory only) @@ -556,10 +638,20 @@ fn apply_regex_ops( // HAZARD: the failing op's MutationEvents are already pushed into // `events` — safety hinges on the WHOLE frame being discarded on // Err; a future resumable/per-op refactor must rewind them. - if let Some(expected) = expect_matches { - if total != *expected { - return Err(expect_matches_mismatch(pattern, total, *expected)); + match count_rule { + CountRule::ExpectMatches { + pattern, + expect: Some(expected), + } if total != expected => { + return Err(expect_matches_mismatch(pattern, total, expected)); + } + CountRule::Unique { + old_str, + replace_all, + } if total == 0 || (!replace_all && total > 1) => { + return Err(str_replace_mismatch(old_str, total)); } + _ => {} } bytes = out.into_bytes(); for (first, last, events_after) in sites { @@ -636,6 +728,27 @@ fn expect_matches_mismatch(pattern: &str, actual: u64, expected: u64) -> CoderEr } } +/// Prescriptive C210 for a `str_replace` count violation: `old_str` was not +/// found (0 matches — stale or re-typed text) or is ambiguous (>1 match +/// without `replace_all`). Names the corrective next call in both cases. +fn str_replace_mismatch(old_str: &str, actual: u64) -> CoderError { + let shown = pattern_snippet(old_str); + if actual == 0 { + CoderError::BadInput(format!( + "str_replace old_str {shown} not found in the file — re-read the \ + file (coder::read-file) and copy the exact current text, \ + including whitespace and indentation" + )) + } else { + CoderError::BadInput(format!( + "str_replace old_str {shown} matched {} — include more \ + surrounding lines in old_str to make it unique, or set \ + replace_all: true to replace every occurrence", + times(actual) + )) + } +} + // --------------------------------------------------------------------------- // R1 (v0.4.1) — pre-write validation of replacement capture references. // @@ -1011,7 +1124,7 @@ fn apply_line_ops(lines: &mut Vec, ops: &[&UpdateOp]) -> Result<(), Code let new_lines = split_content(content); lines.splice(a..b, new_lines); } - UpdateOp::Replace { .. } => unreachable!("line ops only"), + UpdateOp::Replace { .. } | UpdateOp::StrReplace { .. } => unreachable!("line ops only"), } } Ok(()) @@ -1036,9 +1149,12 @@ pub fn apply_ops( if ops.is_empty() { return Err(CoderError::BadInput("ops must not be empty".into())); } - if ops.iter().any(|op| matches!(op, UpdateOp::Replace { .. })) { + if ops + .iter() + .any(|op| matches!(op, UpdateOp::Replace { .. } | UpdateOp::StrReplace { .. })) + { return Err(CoderError::BadInput( - "apply_ops does not support regex replace ops".into(), + "apply_ops does not support content ops".into(), )); } let refs: Vec<&UpdateOp> = ops.iter().collect(); @@ -1052,7 +1168,7 @@ fn anchor(op: &UpdateOp) -> u32 { UpdateOp::Insert { at_line, .. } => *at_line, UpdateOp::Remove { from_line, .. } => *from_line, UpdateOp::UpdateLines { from_line, .. } => *from_line, - UpdateOp::Replace { .. } => 0, + UpdateOp::Replace { .. } | UpdateOp::StrReplace { .. } => 0, } } @@ -1078,7 +1194,7 @@ fn validate_line_ops(ops: &[&UpdateOp], original_len: usize) -> Result<(), Coder ))); } } - UpdateOp::Replace { .. } => unreachable!("line ops only"), + UpdateOp::Replace { .. } | UpdateOp::StrReplace { .. } => unreachable!("line ops only"), } } for i in 0..ops.len() { @@ -1110,7 +1226,7 @@ fn cover(op: &UpdateOp) -> (u32, u32) { UpdateOp::UpdateLines { from_line, to_line, .. } => (*from_line, *to_line), - UpdateOp::Replace { .. } => (0, 0), + UpdateOp::Replace { .. } | UpdateOp::StrReplace { .. } => (0, 0), } } @@ -1584,6 +1700,105 @@ mod tests { assert_eq!(err.code(), "C210"); } + fn str_replace(old: &str, new: &str, replace_all: bool) -> UpdateOp { + UpdateOp::StrReplace { + old_str: old.into(), + new_str: new.into(), + replace_all, + } + } + + #[test] + fn str_replace_single_occurrence() { + let out = apply_regex_replaces( + b"foo bar baz".to_vec(), + &[&str_replace("bar", "qux", false)], + ) + .unwrap(); + assert_eq!(out, b"foo qux baz"); + } + + #[test] + fn str_replace_is_literal_not_regex() { + let out = apply_regex_replaces( + b"price is $x.*y".to_vec(), + &[&str_replace("$x.*y", "$z", false)], + ) + .unwrap(); + assert_eq!(out, b"price is $z"); + } + + #[test] + fn str_replace_dollar_in_new_str_lands_verbatim() { + // The classic template-literal collision: `${name}` must survive + // expansion untouched (regex `replace` requires `$$` for this). + let out = apply_regex_replaces( + b"greet('world')".to_vec(), + &[&str_replace("'world'", "`Hello, ${name}!`", false)], + ) + .unwrap(); + assert_eq!(out, b"greet(`Hello, ${name}!`)"); + } + + #[test] + fn str_replace_multiline_old_str() { + let out = apply_regex_replaces( + b"fn a() {\n 1\n}\nfn b() {}".to_vec(), + &[&str_replace( + "fn a() {\n 1\n}", + "fn a() {\n 2\n}", + false, + )], + ) + .unwrap(); + assert_eq!(out, b"fn a() {\n 2\n}\nfn b() {}"); + } + + #[test] + fn str_replace_not_found_is_c210() { + let err = apply_regex_replaces(b"foo bar".to_vec(), &[&str_replace("qux", "x", false)]) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + assert!(err.to_string().contains("not found"), "{err}"); + } + + #[test] + fn str_replace_ambiguous_without_replace_all_is_c210() { + let err = apply_regex_replaces(b"foo foo foo".to_vec(), &[&str_replace("foo", "x", false)]) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + assert!(err.to_string().contains("replace_all"), "{err}"); + } + + #[test] + fn str_replace_replace_all_replaces_every_occurrence() { + let out = apply_regex_replaces(b"foo foo foo".to_vec(), &[&str_replace("foo", "x", true)]) + .unwrap(); + assert_eq!(out, b"x x x"); + } + + #[test] + fn str_replace_replace_all_still_requires_a_match() { + let err = apply_regex_replaces(b"foo bar".to_vec(), &[&str_replace("qux", "x", true)]) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn str_replace_empty_old_str_rejected() { + let err = + apply_regex_replaces(b"foo".to_vec(), &[&str_replace("", "x", false)]).unwrap_err(); + assert_eq!(err.code(), "C210"); + assert!(err.to_string().contains("must not be empty"), "{err}"); + } + + #[test] + fn str_replace_new_str_may_be_empty() { + let out = apply_regex_replaces(b"foo bar baz".to_vec(), &[&str_replace(" bar", "", false)]) + .unwrap(); + assert_eq!(out, b"foo baz"); + } + // --------------------------------------------------------------------------- // Echo primitive unit tests (the scenario coverage lives in // handler_tests, driven through the real pipeline) diff --git a/shell/tests/code_context.rs b/shell/tests/code_context.rs new file mode 100644 index 000000000..a21062c10 --- /dev/null +++ b/shell/tests/code_context.rs @@ -0,0 +1,89 @@ +//! Integration coverage for `coder::context` — the one-call workspace +//! snapshot. Exercises the real handler against a temp git repo and a +//! plain directory (git: null). + +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; + +use shell::code::config::CoderConfig; +use shell::code::functions::context::{handle as context_handle, ContextInput}; +use shell::code::path::PathResolver; +use tempfile::tempdir; + +fn make(base: PathBuf) -> (Arc, Arc) { + let cfg = Arc::new(CoderConfig { + base_paths: vec![base], + ..CoderConfig::default() + }); + let r = Arc::new(PathResolver::new(&cfg).unwrap()); + (r, cfg) +} + +fn git(root: &std::path::Path, args: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(root) + .args(["-c", "user.email=t@t", "-c", "user.name=t"]) + .args(args) + .status() + .expect("git binary available in test env"); + assert!(status.success(), "git {args:?} failed"); +} + +#[tokio::test] +async fn context_reports_git_state_and_instruction_files() { + let tmp = tempdir().unwrap(); + let root = tmp.path(); + git(root, &["init", "-b", "main"]); + std::fs::write(root.join("lib.rs"), "fn main() {}\n").unwrap(); + std::fs::write(root.join("AGENTS.md"), "# Conventions\nuse tabs\n").unwrap(); + git(root, &["add", "."]); + git(root, &["commit", "-m", "initial"]); + std::fs::write(root.join("dirty.txt"), "uncommitted\n").unwrap(); + + let (r, c) = make(root.to_path_buf()); + let out = context_handle(r, c, ContextInput::default()).await.unwrap(); + + assert!(!out.primary_root.is_empty()); + assert_eq!(out.base_paths[0], out.primary_root); + assert!(!out.platform.os.is_empty() && !out.platform.arch.is_empty()); + + let git_ctx = out.git.expect("temp dir is a git repo"); + assert_eq!(git_ctx.branch, "main"); + assert!(!git_ctx.status_truncated); + assert!( + git_ctx.status.iter().any(|l| l.contains("dirty.txt")), + "porcelain status lists the uncommitted file: {:?}", + git_ctx.status + ); + assert_eq!(git_ctx.recent_commits.len(), 1); + assert!(git_ctx.recent_commits[0].contains("initial")); + + assert_eq!(out.instruction_files.len(), 1); + assert_eq!(out.instruction_files[0].path, "AGENTS.md"); + assert!(out.instruction_files[0].content.contains("use tabs")); + assert!(!out.instruction_files[0].truncated); +} + +#[tokio::test] +async fn context_on_plain_directory_has_null_git_and_no_files() { + let tmp = tempdir().unwrap(); + let (r, c) = make(tmp.path().to_path_buf()); + let out = context_handle(r, c, ContextInput::default()).await.unwrap(); + assert!(out.git.is_none()); + assert!(out.instruction_files.is_empty()); +} + +#[tokio::test] +async fn context_caps_oversized_instruction_file() { + let tmp = tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("CLAUDE.md"), "x".repeat(20 * 1024)).unwrap(); + let (r, c) = make(root.to_path_buf()); + let out = context_handle(r, c, ContextInput::default()).await.unwrap(); + assert_eq!(out.instruction_files.len(), 1); + assert_eq!(out.instruction_files[0].path, "CLAUDE.md"); + assert!(out.instruction_files[0].truncated); + assert_eq!(out.instruction_files[0].content.len(), 16 * 1024); +} diff --git a/shell/tests/code_golden_schemas.rs b/shell/tests/code_golden_schemas.rs index 01b32aeff..0c39f0681 100644 --- a/shell/tests/code_golden_schemas.rs +++ b/shell/tests/code_golden_schemas.rs @@ -1,4 +1,4 @@ -//! GOLDEN FAMILY A — wire-schema snapshots for all 9 `coder::*` functions +//! GOLDEN FAMILY A — wire-schema snapshots for all 10 `coder::*` functions //! served by the shell worker. //! //! `shell::code::functions::catalog()` is the single source of truth for @@ -35,15 +35,16 @@ fn spec_to_pretty_json(spec: &FunctionSpec) -> String { pretty } -/// The catalog must cover exactly the 9 registered functions, in +/// The catalog must cover exactly the 10 registered functions, in /// registration order (kept in lockstep with `register_all`). #[test] -fn catalog_lists_all_nine_functions_in_registration_order() { +fn catalog_lists_all_ten_functions_in_registration_order() { let ids: Vec<&str> = catalog().iter().map(|s| s.function_id).collect(); assert_eq!( ids, vec![ "coder::info", + "coder::context", "coder::read-file", "coder::search", "coder::update-file", @@ -195,22 +196,29 @@ fn search_example_round_trips() { } #[test] -fn update_file_example_round_trips_with_three_op_kinds() { +fn update_file_example_round_trips_with_four_op_kinds() { use shell::code::functions::update_file::{UpdateFileInput, UpdateOp}; let input: UpdateFileInput = example_as("coder::update-file", 0); assert_eq!(input.files.len(), 1); let ops = &input.files[0].ops; - assert_eq!(ops.len(), 3); - assert!(matches!(ops[0], UpdateOp::Insert { at_line: 1, .. })); + assert_eq!(ops.len(), 4); assert!(matches!( - ops[1], + ops[0], + UpdateOp::StrReplace { + replace_all: false, + .. + } + )); + assert!(matches!(ops[1], UpdateOp::Insert { at_line: 1, .. })); + assert!(matches!( + ops[2], UpdateOp::UpdateLines { from_line: 5, to_line: 7, .. } )); - assert!(matches!(ops[2], UpdateOp::Replace { .. })); + assert!(matches!(ops[3], UpdateOp::Replace { .. })); } #[test] diff --git a/shell/tests/code_update_ops.rs b/shell/tests/code_update_ops.rs index 7fb39971c..eaee86e3c 100644 --- a/shell/tests/code_update_ops.rs +++ b/shell/tests/code_update_ops.rs @@ -190,3 +190,83 @@ async fn regex_replace_e2e() { "baz bar baz\n" ); } + +#[tokio::test] +async fn str_replace_e2e_with_echo() { + let tmp = tempdir().unwrap(); + std::fs::write( + tmp.path().join("a.rs"), + "fn hello() {\n println!(\"hallo\");\n}\n", + ) + .unwrap(); + let (r, c) = make(tmp.path().to_path_buf(), vec![]); + let out = update_handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "a.rs".into(), + ops: vec![UpdateOp::StrReplace { + old_str: "println!(\"hallo\")".into(), + new_str: "println!(\"hello\")".into(), + replace_all: false, + }], + }], + fs_scope: None, + }, + ) + .await + .unwrap(); + let res = &out.results[0]; + assert!(res.success, "{:?}", res.error); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(), + "fn hello() {\n println!(\"hello\");\n}\n" + ); + // Content ops echo their match site with the total replacement count. + assert_eq!(res.echoes.len(), 1); + assert_eq!(res.echoes[0].total_replacements, Some(1)); + assert!(res.echoes[0].lines.iter().any(|l| l.contains("hello"))); +} + +#[tokio::test] +async fn str_replace_ambiguity_failure_leaves_file_untouched() { + let tmp = tempdir().unwrap(); + let original = "foo\nbar\nfoo\n"; + std::fs::write(tmp.path().join("a.txt"), original).unwrap(); + let (r, c) = make(tmp.path().to_path_buf(), vec![]); + let out = update_handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "a.txt".into(), + // The line op would succeed; the ambiguous str_replace must + // fail the WHOLE file with nothing written. + ops: vec![ + UpdateOp::Insert { + at_line: 1, + content: "header".into(), + }, + UpdateOp::StrReplace { + old_str: "foo".into(), + new_str: "qux".into(), + replace_all: false, + }, + ], + }], + fs_scope: None, + }, + ) + .await + .unwrap(); + let res = &out.results[0]; + assert!(!res.success); + let err = res.error.as_ref().unwrap(); + assert_eq!(err.code, "C210"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + original, + "failed str_replace must leave the file byte-identical" + ); +} diff --git a/shell/tests/golden/schemas/coder.context.json b/shell/tests/golden/schemas/coder.context.json new file mode 100644 index 000000000..b258e2b1e --- /dev/null +++ b/shell/tests/golden/schemas/coder.context.json @@ -0,0 +1,132 @@ +{ + "description": "One-call workspace snapshot for seeding a coding session: the allowed roots (primary first), platform os/arch, bounded git state of the primary root (current branch, porcelain status capped at 50 entries, last 5 commits), and repo instruction files (AGENTS.md / CLAUDE.md at the primary root, capped at 16 KiB each). Read-only and never fails on a non-repo: `git` is null when the root is not a git work tree or the git binary is unavailable.", + "function_id": "coder::context", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "No caller-visible arguments — `coder::context` is a pure discovery call.", + "examples": [ + {} + ], + "title": "ContextInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "GitContext": { + "properties": { + "branch": { + "description": "Current branch name; `\"HEAD\"` when detached.", + "type": "string" + }, + "recent_commits": { + "description": "Up to the last 5 commits, `git log --oneline` format.", + "items": { + "type": "string" + }, + "type": "array" + }, + "status": { + "description": "`git status --porcelain` lines, capped at 50 entries.", + "items": { + "type": "string" + }, + "type": "array" + }, + "status_truncated": { + "description": "True when the status list was capped.", + "type": "boolean" + } + }, + "required": [ + "branch", + "recent_commits", + "status", + "status_truncated" + ], + "type": "object" + }, + "InstructionFile": { + "properties": { + "content": { + "description": "File content, capped at 16 KiB (char-boundary safe).", + "type": "string" + }, + "path": { + "description": "Root-relative file name (e.g. \"AGENTS.md\").", + "type": "string" + }, + "truncated": { + "description": "True when `content` was capped.", + "type": "boolean" + } + }, + "required": [ + "content", + "path", + "truncated" + ], + "type": "object" + }, + "Platform": { + "properties": { + "arch": { + "description": "CPU architecture (`std::env::consts::ARCH`, e.g. \"aarch64\").", + "type": "string" + }, + "os": { + "description": "Operating system (`std::env::consts::OS`, e.g. \"macos\", \"linux\").", + "type": "string" + } + }, + "required": [ + "arch", + "os" + ], + "type": "object" + } + }, + "properties": { + "base_paths": { + "description": "Canonical absolute paths of all allowed roots, primary first.", + "items": { + "type": "string" + }, + "type": "array" + }, + "git": { + "anyOf": [ + { + "$ref": "#/definitions/GitContext" + }, + { + "type": "null" + } + ], + "description": "Git state of the primary root; `null` when the root is not inside a git work tree or the `git` binary is unavailable." + }, + "instruction_files": { + "description": "Repo instruction files (AGENTS.md, CLAUDE.md) found at the primary root, in probe order. Content is capped per file; `truncated` marks a capped entry.", + "items": { + "$ref": "#/definitions/InstructionFile" + }, + "type": "array" + }, + "platform": { + "$ref": "#/definitions/Platform" + }, + "primary_root": { + "description": "The primary allowed root — relative paths in every `coder::*` call resolve against this directory.", + "type": "string" + } + }, + "required": [ + "base_paths", + "instruction_files", + "platform", + "primary_root" + ], + "title": "ContextOutput", + "type": "object" + } +} diff --git a/shell/tests/golden/schemas/coder.update-file.json b/shell/tests/golden/schemas/coder.update-file.json index c3652c479..425ba2c00 100644 --- a/shell/tests/golden/schemas/coder.update-file.json +++ b/shell/tests/golden/schemas/coder.update-file.json @@ -108,6 +108,36 @@ ], "type": "object" }, + { + "description": "Replace one exact literal occurrence of `old_str` with `new_str` — THE PREFERRED OP for targeted edits. Copy `old_str` verbatim from the file (read it first), including whitespace and indentation; no regex, no escaping. Fails with C210 (nothing written) when `old_str` is not found, or occurs more than once without `replace_all` — include more surrounding lines in `old_str` to make it unique.", + "properties": { + "new_str": { + "description": "Replacement text (may be empty to delete `old_str`).", + "type": "string" + }, + "old_str": { + "description": "Exact existing text to replace, copied verbatim from the file.", + "type": "string" + }, + "op": { + "enum": [ + "str_replace" + ], + "type": "string" + }, + "replace_all": { + "default": false, + "description": "Replace EVERY occurrence instead of requiring exactly one. At least one occurrence is still required.", + "type": "boolean" + } + }, + "required": [ + "new_str", + "old_str", + "op" + ], + "type": "object" + }, { "description": "Replace all regex matches in the file body (after line ops).", "properties": { @@ -159,6 +189,11 @@ "files": [ { "ops": [ + { + "new_str": "fn hello() -> &'static str {\n \"hello\"\n}", + "old_str": "fn hello() -> &'static str {\n \"hallo\"\n}", + "op": "str_replace" + }, { "at_line": 1, "content": "// generated by coder\n",