Skip to main content

newt_coder/
coder.rs

1//! The Coder orchestrator — prompt -> infer -> normalize -> apply.
2//!
3//! One method, [`Coder::run`], wires the four pieces together:
4//!
5//! 1. [`build_prompt`](crate::prompt::build_prompt) scans the workspace
6//!    for relevant files and composes a `(system, user)` pair around
7//!    the S5 whole-file directive.
8//! 2. The injected [`InferenceBackend`] runs one `complete` turn.
9//! 3. [`normalize_emission`](crate::emission::normalize_emission)
10//!    classifies the raw reply as `WholeFiles` / `UnifiedDiff` /
11//!    `Prose`.
12//! 4. The classified emission is applied to the workspace:
13//!    `apply_whole_files` for the directive's happy path,
14//!    `apply_patch` for the diff fallback, no-op + warn for prose.
15//!
16//! The caller (newt-acp-worker) then runs `git diff` to capture the
17//! real workspace diff — the foreman's empty-diff signal is computed
18//! from `git diff`, not from anything in this struct.
19
20use std::path::Path;
21use std::sync::Arc;
22
23use newt_core::{Caveats, CaveatsExt, CountBoundExt};
24use newt_identity::AgentKey;
25use newt_inference::{ChatRequest, InferenceBackend};
26
27use crate::emission::{normalize_emission, Emission};
28use crate::error::{CoderError, Result};
29use crate::prompt::{build_prompt, build_reprompt, CoderPrompt};
30
31/// The coder. Holds the inference backend the orchestrator uses for
32/// each `run` call; the backend is `Arc<dyn …>` so callers can share
33/// one backend across coder + non-coder paths.
34///
35/// # Issue #93 — operator-rooted parent key
36///
37/// `Coder` optionally holds the worker's operator-rooted parent
38/// [`AgentKey`] (from `WorkerIdentity::Operator { root }`). When a
39/// dispatch needs to spawn a subprocess plugin (today: future
40/// `ProviderPluginBackend::complete`), it derives a delegated child
41/// via [`AgentKey::delegate`] so the child's cert chain roots back to
42/// the operator's `UserKey` from `~/.newt/identity.pem` — never a
43/// synthetic key.
44pub struct Coder {
45    backend: Arc<dyn InferenceBackend>,
46    /// Operator-rooted parent key (issue #93). The acp-worker plumbs
47    /// this from `WorkerIdentity::Operator { root }`; when present, any
48    /// subprocess plugin the dispatch spawns inherits a delegated child
49    /// from this parent so the cert chain walks back to the operator.
50    /// `None` for the `WorkerIdentity::AllowNoKey` debug fallback and
51    /// for legacy tests that don't yet plumb identity.
52    parent_key: Option<Arc<AgentKey>>,
53}
54
55/// Outcome of one `Coder::run` turn. Surfaced via the ACP worker's
56/// `TaskReply.emission_shape` so the foreman's scorecard can
57/// distinguish T0a / T0b / T0c instead of lumping them as "empty
58/// diff".
59#[derive(Debug, Clone)]
60pub struct CoderRun {
61    /// Wire-stable shape label: "whole_files", "unified_diff", "prose".
62    pub emission_shape: String,
63    /// Model id the inference backend returned.
64    pub model_id: String,
65    /// Relative paths of files the run wrote (empty for prose / diff
66    /// — the diff path doesn't tell us which files it touched without
67    /// re-parsing).
68    pub files_written: Vec<String>,
69    /// The raw model reply. Useful for audit logs and post-mortem.
70    /// NOTE: when the whole-file re-prompt fallback fires this becomes a
71    /// composite first+retry transcript — use [`Self::first_emission`]
72    /// when you need just the model's initial output.
73    pub raw_reply: String,
74    /// The model's *first* raw emission, before any re-prompt fallback.
75    /// Always the initial reply (never a composite), so the eval
76    /// scorecard can judge it with `git apply --check` (#30B) to tell a
77    /// clean diff from a sloppy one the fuzzy worker merely rescued.
78    pub first_emission: String,
79}
80
81impl Coder {
82    /// Build a coder bound to `backend`.
83    pub fn new(backend: Arc<dyn InferenceBackend>) -> Self {
84        Self {
85            backend,
86            parent_key: None,
87        }
88    }
89
90    /// Builder: attach the operator-rooted parent [`AgentKey`] the coder
91    /// will use to delegate per-spawn children for subprocess plugins
92    /// (issue #93).
93    ///
94    /// The acp-worker plumbs this in from
95    /// `WorkerIdentity::Operator { root }` via
96    /// [`WorkerIdentity::parent_key`]; the `AllowNoKey` debug fallback
97    /// leaves it `None`. When present, [`Self::plugin_envelope_for`]
98    /// can be called by future dispatch paths that need to spawn a
99    /// provider plugin without first synthesizing a key.
100    #[must_use]
101    pub fn with_parent_key(mut self, parent: Arc<AgentKey>) -> Self {
102        self.parent_key = Some(parent);
103        self
104    }
105
106    /// Borrow the parent [`AgentKey`], if one is configured. Tests use
107    /// this to assert the operator-rooted threading; future dispatch
108    /// paths that spawn provider plugins use it to delegate per-spawn
109    /// children.
110    #[must_use]
111    pub fn parent_key(&self) -> Option<&Arc<AgentKey>> {
112        self.parent_key.as_ref()
113    }
114
115    /// Mint a plugin-side envelope for a subprocess running under
116    /// `role` with `child_caveats`, by delegating from the coder's
117    /// operator-rooted parent key.
118    ///
119    /// Returns:
120    /// - `Some(Ok(envelope))` on the happy path — the envelope's cert
121    ///   chain roots back to the operator's `UserKey` (issue #93).
122    /// - `Some(Err(_))` if delegation refused (`child_caveats` would
123    ///   amplify the parent's authority, etc.).
124    /// - `None` when no parent key is configured (the
125    ///   `WorkerIdentity::AllowNoKey` debug path or a legacy test that
126    ///   didn't thread identity).
127    ///
128    /// The returned envelope is the same shape `newt-mesh`'s
129    /// `plugin_envelope::serialize_for_plugin` produces — base64'd JSON
130    /// of an `agent_mesh_protocol::CertChain` — so plugins decode it
131    /// identically.
132    pub fn plugin_envelope_for(
133        &self,
134        role: &str,
135        child_caveats: Caveats,
136    ) -> Option<std::result::Result<String, newt_identity::EnvelopeError>> {
137        let parent = self.parent_key.as_ref()?;
138        Some(newt_identity::delegate_for_plugin(
139            parent.as_ref(),
140            role,
141            child_caveats,
142        ))
143    }
144
145    /// Run one turn against `workspace` under the authority carried by
146    /// `caveats`.
147    ///
148    /// `caveats` is the peer's signed, verified attenuated authority — see
149    /// `docs/decisions/agentic_object_capability_security.md` and the
150    /// 35a [`caveats_for_peer`] extractor in `newt-mesh`. Every tool
151    /// dispatch this method makes (`fs_read` for the prompt scan,
152    /// `net` for the inference call, `fs_write` for the apply, plus the
153    /// `max_calls` budget for inference turns) goes through the
154    /// enforcement helpers below — no path bypasses the check, even when
155    /// `caveats == Caveats::top()`. That symmetry is load-bearing: 35c
156    /// will tighten authority per peer, and a "skip checks if top"
157    /// shortcut would silently break that tightening.
158    ///
159    /// On any caveat refusal we return [`CoderError::CapabilityDenied`]
160    /// carrying the axis name and the concrete target the dispatch tried
161    /// to touch — enough context for the arbiter scorecard to count this
162    /// as a scrubbed sortie rather than a model failure.
163    ///
164    /// Happy path: build prompt -> infer -> normalize -> apply.
165    ///
166    /// Weak-model fallback: when the model emits a [`Emission::UnifiedDiff`]
167    /// (even under the whole-file directive) and that diff fails to apply
168    /// — its line numbers / context are too far off even for the fuzzy
169    /// matcher in `newt-tools::apply_patch` — we issue exactly ONE
170    /// re-prompt asking for the COMPLETE file(s) in `FILE:`/`END-FILE`
171    /// form, then apply via the hardened `apply_whole_files` path. The
172    /// retry counts as a *second* inference call against the
173    /// `max_calls` budget; if that budget would be exhausted we return
174    /// the original apply error rather than escalating to a denial.
175    ///
176    /// [`caveats_for_peer`]: https://docs.rs/newt-mesh/latest/newt_mesh/caveats/fn.caveats_for_peer.html
177    pub async fn run(&self, workspace: &Path, task: &str, caveats: &Caveats) -> Result<CoderRun> {
178        // 1. Build the prompt. `build_prompt` is what *reads* the
179        //    workspace, so the fs_read check is gated on the files the
180        //    prompt actually injected, not on the candidate set the
181        //    scanner considered.
182        let prompt = build_prompt(workspace, task)?;
183        check_fs_read(caveats, &prompt)?;
184        tracing::info!(
185            files_included = prompt.included_files.len(),
186            user_chars = prompt.user.len(),
187            "newt-coder prompt built"
188        );
189
190        // 2. First inference call — guarded by the net + max_calls axes.
191        let mut calls_used: u64 = 0;
192        check_call_budget(caveats, calls_used)?;
193        check_net(caveats, self.backend.as_ref())?;
194        let req = ChatRequest::new().system(prompt.system).user(prompt.user);
195        let reply = self
196            .backend
197            .complete(req)
198            .await
199            .map_err(|e| CoderError::Inference(e.to_string()))?;
200        calls_used += 1;
201        let raw = reply.content.clone();
202        let model_id = reply.model_id.clone();
203
204        let emission = normalize_emission(&raw)?;
205        let shape_label = emission.shape_label().to_string();
206
207        // 3. Try to apply the first emission — `apply` consults the
208        //    fs_write axis before each write.
209        match self.apply(&emission, workspace, caveats) {
210            Ok(files_written) => {
211                tracing::info!(
212                    emission_shape = %shape_label,
213                    files_written = files_written.len(),
214                    "newt-coder run complete"
215                );
216                Ok(CoderRun {
217                    emission_shape: shape_label,
218                    model_id,
219                    files_written,
220                    first_emission: raw.clone(),
221                    raw_reply: raw,
222                })
223            }
224            // The first emission was diff-shaped and did not apply: either a
225            // unified diff whose context was too far off even for the fuzzy
226            // matcher, or diff content the model wrapped in FILE:/END-FILE
227            // markers (classified as whole-files but rejected by the
228            // diff-shape guard). Both are recoverable with a single re-prompt
229            // for proper whole-file output.
230            Err(first_err)
231                if matches!(emission, Emission::UnifiedDiff(_))
232                    || matches!(first_err, CoderError::LooksLikeDiff { .. }) =>
233            {
234                tracing::warn!(
235                    error = %first_err,
236                    "newt-coder: diff-shaped emission did not apply, re-prompting for whole files"
237                );
238                self.reprompt_whole_files(workspace, task, raw, first_err, calls_used, caveats)
239                    .await
240            }
241            Err(other) => Err(other),
242        }
243    }
244
245    /// Single-retry fallback: re-prompt the model for the complete
246    /// file(s) and apply via `apply_whole_files`.
247    ///
248    /// Bounded to ONE additional inference call — there is no loop. The
249    /// retry counts as a *second* tool call against
250    /// [`Caveats::max_calls`], and if the budget would be exhausted by
251    /// that second call we fall through to `original_err` (the apply
252    /// failure from the first attempt). On any failure of the retry
253    /// (inference error, the model returning yet another diff / prose,
254    /// or the whole-file apply failing the shape guards or fs_write
255    /// caveat) we return `original_err`, so the caller sees the root
256    /// cause rather than a confusing second-order failure.
257    async fn reprompt_whole_files(
258        &self,
259        workspace: &Path,
260        task: &str,
261        first_raw: String,
262        original_err: CoderError,
263        calls_used: u64,
264        caveats: &Caveats,
265    ) -> Result<CoderRun> {
266        // The retry would be the (calls_used + 1)-th call; if the
267        // budget can't cover it, don't degrade the diagnostic by
268        // surfacing a fresh capability denial — keep the original
269        // apply failure, which is more actionable.
270        if !caveats.max_calls.permits_one_more(calls_used) {
271            tracing::warn!(
272                calls_used,
273                "newt-coder: re-prompt skipped, max_calls budget exhausted"
274            );
275            return Err(original_err);
276        }
277
278        let prompt = match build_reprompt(workspace, task) {
279            Ok(p) => p,
280            Err(e) => {
281                tracing::warn!(error = %e, "newt-coder: re-prompt build failed");
282                return Err(original_err);
283            }
284        };
285        // The re-prompt re-reads the same workspace; fs_read scope must
286        // still permit every file the second pass would inject.
287        if let Err(e) = check_fs_read(caveats, &prompt) {
288            tracing::warn!(error = %e, "newt-coder: re-prompt fs_read denied");
289            return Err(original_err);
290        }
291
292        let req = ChatRequest::new().system(prompt.system).user(prompt.user);
293        let reply = match self.backend.complete(req).await {
294            Ok(r) => r,
295            Err(e) => {
296                tracing::warn!(error = %e, "newt-coder: re-prompt inference failed");
297                return Err(original_err);
298            }
299        };
300        let retry_raw = reply.content.clone();
301        let model_id = reply.model_id.clone();
302
303        // The retry must yield whole files; anything else (another diff,
304        // prose) is not usable for this fallback.
305        let emission = match normalize_emission(&retry_raw) {
306            Ok(em @ Emission::WholeFiles(_)) => em,
307            Ok(other) => {
308                tracing::warn!(
309                    emission_shape = %other.shape_label(),
310                    "newt-coder: re-prompt did not return whole files"
311                );
312                return Err(original_err);
313            }
314            Err(e) => {
315                tracing::warn!(error = %e, "newt-coder: re-prompt emission malformed");
316                return Err(original_err);
317            }
318        };
319
320        let shape_label = emission.shape_label().to_string();
321        match self.apply(&emission, workspace, caveats) {
322            Ok(files_written) => {
323                tracing::info!(
324                    emission_shape = %shape_label,
325                    files_written = files_written.len(),
326                    "newt-coder: re-prompt whole-file fallback applied"
327                );
328                Ok(CoderRun {
329                    // Reflect what *actually* applied: the whole-file retry,
330                    // not the original diff.
331                    emission_shape: shape_label,
332                    model_id,
333                    files_written,
334                    // The first emission is the diff the model actually
335                    // produced for the task; the scorecard judges *that*,
336                    // not the rescued retry.
337                    first_emission: first_raw.clone(),
338                    // Keep an audit trail of both turns: the first
339                    // (rejected) diff and the retry that landed.
340                    raw_reply: format!(
341                        "[diff-apply failed, re-prompted for whole files]\n\
342                         --- first reply ---\n{first_raw}\n\
343                         --- retry reply ---\n{retry_raw}"
344                    ),
345                })
346            }
347            Err(e) => {
348                tracing::warn!(
349                    error = %e,
350                    "newt-coder: re-prompt whole-file apply failed"
351                );
352                Err(original_err)
353            }
354        }
355    }
356
357    /// Apply one classified emission to `workspace`, under `caveats`.
358    /// Returns the list of relative paths written, where known.
359    ///
360    /// Every filesystem write goes through the `fs_write` axis first.
361    /// For a [`Emission::WholeFiles`] emission we know every target
362    /// path up front, so the check happens before any write touches
363    /// disk — partial-apply is never possible under a denied caveat.
364    /// For a [`Emission::UnifiedDiff`] we cannot enumerate paths
365    /// without re-parsing, so we require `fs_write` to be
366    /// [`Scope::All`](newt_core::Scope::All) — bounded fs_write +
367    /// diff emission is a denial. This is conservative on purpose:
368    /// 35c will swap diff dispatch for a parser that knows the paths,
369    /// and the conservative rule is easier to weaken later than to
370    /// retrofit a "we already wrote half the diff" rollback.
371    fn apply(
372        &self,
373        emission: &Emission,
374        workspace: &Path,
375        caveats: &Caveats,
376    ) -> Result<Vec<String>> {
377        match emission {
378            Emission::WholeFiles(files) => {
379                // Shape guards before writing. A whole-file emission
380                // legitimately rewrites every line (renames, signature
381                // changes, new doc comments), so we do NOT compare the
382                // body against what's on disk. We reject only bodies
383                // whose *shape* is wrong; the real correctness gate is
384                // the downstream `git diff` capture plus the eval
385                // compile/test evaluators.
386                for (path, contents) in files {
387                    reject_bad_shape(path, contents)?;
388                }
389                // Caveat check: every target path must be permitted on
390                // the fs_write axis. We loop *all* paths before
391                // committing any write so a denial on the second file
392                // can't leave the first file half-written.
393                for path in files.keys() {
394                    if !caveats.permits_fs_write(path) {
395                        return Err(CoderError::CapabilityDenied {
396                            kind: "fs_write",
397                            target: path.clone(),
398                        });
399                    }
400                }
401                // `apply_whole_files` wants `(String, String)` tuples;
402                // collect to give it owned values without leaking the
403                // BTreeMap iterator's lifetime into the call.
404                let pairs: Vec<(String, String)> =
405                    files.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
406                let written = newt_tools::apply_whole_files(workspace, pairs)
407                    .map_err(|e| CoderError::FileWrite(e.to_string()))?;
408                Ok(written)
409            }
410            Emission::UnifiedDiff(diff) => {
411                // We can't enumerate the touched paths without
412                // re-parsing the diff. Be conservative: require
413                // `fs_write = All`. Anything narrower denies the
414                // dispatch up front. Target the diff blob itself so
415                // the error message points the reader at the
416                // can't-enumerate-paths reason.
417                if !matches!(caveats.fs_write, newt_core::Scope::All) {
418                    return Err(CoderError::CapabilityDenied {
419                        kind: "fs_write",
420                        target: "<unified_diff: paths not enumerable>".to_string(),
421                    });
422                }
423                // Legacy path: model emitted a real diff. We don't
424                // know which files it touched without re-parsing, so
425                // return an empty `files_written` — the caller's
426                // `git diff` capture is the source of truth.
427                newt_tools::apply_patch(workspace, diff)
428                    .map_err(|e| CoderError::FileWrite(e.to_string()))?;
429                Ok(Vec::new())
430            }
431            Emission::Prose(prose) => {
432                tracing::warn!(
433                    prose_len = prose.len(),
434                    "newt-coder: prose-only emission, no edits"
435                );
436                Ok(Vec::new())
437            }
438        }
439    }
440}
441
442// ── Enforcement helpers ────────────────────────────────────────────────────
443//
444// One helper per axis the dispatch sites consult. Every helper goes through
445// `Caveats::permits_*` even when the caveat is `top` — there is no fast-path
446// bypass, by design. See the module/`Coder::run` doc comments.
447
448/// Check whether `caveats.max_calls` permits one more inference call
449/// given `used_so_far` calls already counted against this run.
450fn check_call_budget(caveats: &Caveats, used_so_far: u64) -> Result<()> {
451    if caveats.max_calls.permits_one_more(used_so_far) {
452        Ok(())
453    } else {
454        Err(CoderError::CapabilityDenied {
455            kind: "max_calls",
456            target: format!("turn #{}", used_so_far + 1),
457        })
458    }
459}
460
461/// Check whether `caveats.net` permits the network call the backend
462/// would make on `complete()`. Backends with no endpoint (mocks,
463/// in-process plugins) skip the check vacuously — there is no host to
464/// consult.
465fn check_net(caveats: &Caveats, backend: &dyn InferenceBackend) -> Result<()> {
466    let endpoint = match backend.endpoint() {
467        Some(e) => e,
468        None => return Ok(()),
469    };
470    let host = host_from_endpoint(endpoint);
471    if caveats.permits_net(host) {
472        Ok(())
473    } else {
474        Err(CoderError::CapabilityDenied {
475            kind: "net",
476            target: host.to_string(),
477        })
478    }
479}
480
481/// Check whether `caveats.fs_read` permits every file the prompt
482/// actually injected. We gate on `included_files` (what was read), not
483/// on the wider candidate set the scanner considered, so the denial
484/// fires only when the model would have *seen* a forbidden path.
485fn check_fs_read(caveats: &Caveats, prompt: &CoderPrompt) -> Result<()> {
486    for path in &prompt.included_files {
487        let s = path.to_string_lossy();
488        if !caveats.permits_fs_read(&s) {
489            return Err(CoderError::CapabilityDenied {
490                kind: "fs_read",
491                target: s.into_owned(),
492            });
493        }
494    }
495    Ok(())
496}
497
498/// Extract the host portion of an HTTP(S) URL — enough for the
499/// `caveats.net` exact-match check, without dragging in a `url` crate
500/// dependency. Strips `scheme://`, then takes everything up to the
501/// first `/`, `?`, or port `:`. Returns the input unchanged if no
502/// scheme prefix is present (treating it as already a bare host).
503fn host_from_endpoint(endpoint: &str) -> &str {
504    let after_scheme = endpoint
505        .find("://")
506        .map(|i| &endpoint[i + 3..])
507        .unwrap_or(endpoint);
508    let end = after_scheme
509        .find(['/', ':', '?'])
510        .unwrap_or(after_scheme.len());
511    &after_scheme[..end]
512}
513
514/// Reject a whole-file emission whose body has the wrong *shape*.
515///
516/// This replaces the old "first non-blank line must equal the file's
517/// existing anchor line" check, which wrongly rejected correct output
518/// whenever a rename or signature change altered line 1. Instead we
519/// only refuse bodies that are:
520///
521/// - empty / whitespace-only ([`CoderError::EmptyEmission`]),
522/// - diff-shaped — first non-blank line starts with `--- `, `+++ `, or
523///   `@@` ([`CoderError::LooksLikeDiff`]), or
524/// - still prefixed with a leaked `FILE:` marker as their first
525///   non-blank line ([`CoderError::LeakedMarker`]) — defense in depth
526///   in case [`crate::emission`] did not strip it.
527fn reject_bad_shape(path: &str, contents: &str) -> Result<()> {
528    let first_non_blank = contents.lines().find(|l| !l.trim().is_empty());
529    match first_non_blank {
530        None => Err(CoderError::EmptyEmission {
531            path: path.to_string(),
532        }),
533        Some(first) => {
534            let trimmed = first.trim_start();
535            if trimmed.starts_with("--- ")
536                || trimmed.starts_with("+++ ")
537                || trimmed.starts_with("@@")
538            {
539                return Err(CoderError::LooksLikeDiff {
540                    path: path.to_string(),
541                });
542            }
543            if trimmed.starts_with("FILE:") {
544                return Err(CoderError::LeakedMarker {
545                    path: path.to_string(),
546                });
547            }
548            Ok(())
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use std::collections::BTreeMap;
557    use std::fs;
558    use tempfile::TempDir;
559
560    // Apply-only tests; the end-to-end smoke (build_prompt -> backend
561    // -> normalize -> apply) lives in tests/coder_smoke.rs.
562
563    fn coder_with_no_backend_used() -> Coder {
564        // The `apply` method does not call the backend, so we can use
565        // any backend here. We construct one only so the type checks.
566        // Tests in tests/ use a real MockBackend for the run() path.
567        struct Stub;
568        #[async_trait::async_trait]
569        impl InferenceBackend for Stub {
570            fn name(&self) -> &str {
571                "stub"
572            }
573            fn model_id(&self) -> &str {
574                "stub-model"
575            }
576            fn supports_tier(&self, _t: newt_core::router::Tier) -> bool {
577                false
578            }
579            async fn complete(
580                &self,
581                _req: ChatRequest,
582            ) -> anyhow::Result<newt_inference::ChatReply> {
583                unreachable!("apply tests do not call the backend")
584            }
585        }
586        Coder::new(Arc::new(Stub))
587    }
588
589    #[test]
590    fn apply_whole_files_writes_to_workspace() {
591        let tmp = TempDir::new().unwrap();
592        let coder = coder_with_no_backend_used();
593
594        let mut files = BTreeMap::new();
595        files.insert("src/lib.rs".to_string(), "pub fn hello() {}\n".to_string());
596
597        let written = coder
598            .apply(&Emission::WholeFiles(files), tmp.path(), &Caveats::top())
599            .unwrap();
600        assert_eq!(written, vec!["src/lib.rs".to_string()]);
601        let content = fs::read_to_string(tmp.path().join("src/lib.rs")).unwrap();
602        assert_eq!(content, "pub fn hello() {}\n");
603    }
604
605    #[test]
606    fn apply_prose_writes_nothing() {
607        let tmp = TempDir::new().unwrap();
608        let coder = coder_with_no_backend_used();
609        let written = coder
610            .apply(
611                &Emission::Prose("I've updated it.".to_string()),
612                tmp.path(),
613                &Caveats::top(),
614            )
615            .unwrap();
616        assert!(written.is_empty());
617    }
618
619    #[test]
620    fn apply_unified_diff_returns_empty_files_written() {
621        let tmp = TempDir::new().unwrap();
622        // Seed a file so the diff actually applies.
623        fs::write(tmp.path().join("a.txt"), "old\n").unwrap();
624        let diff = "\
625--- a/a.txt
626+++ b/a.txt
627@@ -1 +1 @@
628-old
629+new
630";
631        let coder = coder_with_no_backend_used();
632        let written = coder
633            .apply(
634                &Emission::UnifiedDiff(diff.to_string()),
635                tmp.path(),
636                &Caveats::top(),
637            )
638            .unwrap();
639        assert!(written.is_empty(), "diff path returns empty files_written");
640        let content = fs::read_to_string(tmp.path().join("a.txt")).unwrap();
641        assert_eq!(content, "new\n");
642    }
643
644    #[test]
645    fn apply_bad_diff_surfaces_filewrite_error() {
646        let tmp = TempDir::new().unwrap();
647        let coder = coder_with_no_backend_used();
648        let bad = Emission::UnifiedDiff("not a real diff".to_string());
649        let err = coder.apply(&bad, tmp.path(), &Caveats::top()).unwrap_err();
650        assert!(matches!(err, CoderError::FileWrite(_)));
651    }
652
653    fn whole_files(path: &str, contents: &str) -> Emission {
654        let mut m = BTreeMap::new();
655        m.insert(path.to_string(), contents.to_string());
656        Emission::WholeFiles(m)
657    }
658
659    #[test]
660    fn apply_whole_files_accepts_line_one_change() {
661        // Regression for failures 1 & 2 (rename / signature change):
662        // the emitted first line differs from the existing first line,
663        // which the old anchor check wrongly rejected. It must now apply.
664        let tmp = TempDir::new().unwrap();
665        let coder = coder_with_no_backend_used();
666        fs::create_dir_all(tmp.path().join("src")).unwrap();
667        fs::write(
668            tmp.path().join("src/lib.rs"),
669            "pub fn hello(name: &str) -> String {\n    format!(\"hi {name}\")\n}\n",
670        )
671        .unwrap();
672
673        let new_body = "pub fn greet(name: &str) -> String {\n    format!(\"hi {name}\")\n}\n";
674        let written = coder
675            .apply(
676                &whole_files("src/lib.rs", new_body),
677                tmp.path(),
678                &Caveats::top(),
679            )
680            .unwrap();
681        assert_eq!(written, vec!["src/lib.rs".to_string()]);
682        assert_eq!(
683            fs::read_to_string(tmp.path().join("src/lib.rs")).unwrap(),
684            new_body
685        );
686    }
687
688    #[test]
689    fn apply_whole_files_rejects_diff_shaped_contents() {
690        let tmp = TempDir::new().unwrap();
691        let coder = coder_with_no_backend_used();
692        fs::write(tmp.path().join("a.txt"), "old\n").unwrap();
693        let diff = "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n";
694        let err = coder
695            .apply(&whole_files("a.txt", diff), tmp.path(), &Caveats::top())
696            .unwrap_err();
697        assert!(matches!(err, CoderError::LooksLikeDiff { ref path } if path == "a.txt"));
698        // The file must not have been overwritten.
699        assert_eq!(
700            fs::read_to_string(tmp.path().join("a.txt")).unwrap(),
701            "old\n"
702        );
703    }
704
705    #[test]
706    fn apply_whole_files_rejects_hunk_only_contents() {
707        let tmp = TempDir::new().unwrap();
708        let coder = coder_with_no_backend_used();
709        let hunk = "@@ -1,2 +1,2 @@\n-old\n+new\n";
710        let err = coder
711            .apply(&whole_files("a.txt", hunk), tmp.path(), &Caveats::top())
712            .unwrap_err();
713        assert!(matches!(err, CoderError::LooksLikeDiff { .. }));
714    }
715
716    #[test]
717    fn apply_whole_files_rejects_empty_contents() {
718        let tmp = TempDir::new().unwrap();
719        let coder = coder_with_no_backend_used();
720        let err = coder
721            .apply(&whole_files("a.txt", ""), tmp.path(), &Caveats::top())
722            .unwrap_err();
723        assert!(matches!(err, CoderError::EmptyEmission { ref path } if path == "a.txt"));
724    }
725
726    #[test]
727    fn apply_whole_files_rejects_whitespace_only_contents() {
728        let tmp = TempDir::new().unwrap();
729        let coder = coder_with_no_backend_used();
730        let err = coder
731            .apply(
732                &whole_files("a.txt", "   \n\t\n"),
733                tmp.path(),
734                &Caveats::top(),
735            )
736            .unwrap_err();
737        assert!(matches!(err, CoderError::EmptyEmission { .. }));
738    }
739
740    #[test]
741    fn apply_whole_files_rejects_leaked_file_marker() {
742        // Defense in depth (failures 3 & 4): even if a leaked FILE:
743        // marker slipped past the parser, the writer must refuse it.
744        let tmp = TempDir::new().unwrap();
745        let coder = coder_with_no_backend_used();
746        let body = "FILE: src/lib.rs\npub fn add(a: i32, b: i32) -> i32 { a + b }\n";
747        let err = coder
748            .apply(
749                &whole_files("src/lib.rs", body),
750                tmp.path(),
751                &Caveats::top(),
752            )
753            .unwrap_err();
754        assert!(matches!(err, CoderError::LeakedMarker { ref path } if path == "src/lib.rs"));
755    }
756
757    #[test]
758    fn reject_bad_shape_messages_start_with_file_write_failed() {
759        for err in [
760            super::reject_bad_shape("p", "").unwrap_err(),
761            super::reject_bad_shape("p", "--- a/p\n").unwrap_err(),
762            super::reject_bad_shape("p", "FILE: p\n").unwrap_err(),
763        ] {
764            assert!(
765                err.to_string().starts_with("file write failed:"),
766                "message did not start with prefix: {err}"
767            );
768        }
769    }
770
771    // ── Caveat enforcement at the apply boundary ─────────────────────────
772
773    #[test]
774    fn apply_whole_files_denies_path_outside_fs_write_scope() {
775        let tmp = TempDir::new().unwrap();
776        let coder = coder_with_no_backend_used();
777        let caveats = Caveats {
778            fs_write: newt_core::Scope::only(["allowed.rs".to_string()]),
779            ..Caveats::top()
780        };
781
782        // Allowed write succeeds.
783        let allowed = coder
784            .apply(
785                &whole_files("allowed.rs", "fn ok() {}\n"),
786                tmp.path(),
787                &caveats,
788            )
789            .expect("permitted write must succeed");
790        assert_eq!(allowed, vec!["allowed.rs".to_string()]);
791
792        // Forbidden write returns CapabilityDenied.
793        let err = coder
794            .apply(
795                &whole_files("forbidden.rs", "fn evil() {}\n"),
796                tmp.path(),
797                &caveats,
798            )
799            .unwrap_err();
800        match err {
801            CoderError::CapabilityDenied { kind, target } => {
802                assert_eq!(kind, "fs_write");
803                assert_eq!(target, "forbidden.rs");
804            }
805            other => panic!("expected CapabilityDenied, got {other:?}"),
806        }
807        // And the file was never created.
808        assert!(!tmp.path().join("forbidden.rs").exists());
809    }
810
811    #[test]
812    fn apply_whole_files_denies_atomically_on_partial_scope() {
813        // A multi-file emission where one path is denied must write
814        // NOTHING — the check loops every path before committing any
815        // write. Regression for the "wrote half the emission then
816        // refused" failure mode.
817        let tmp = TempDir::new().unwrap();
818        let coder = coder_with_no_backend_used();
819        let caveats = Caveats {
820            fs_write: newt_core::Scope::only(["a.rs".to_string()]),
821            ..Caveats::top()
822        };
823        let mut files = BTreeMap::new();
824        files.insert("a.rs".to_string(), "fn a() {}\n".to_string());
825        files.insert("b.rs".to_string(), "fn b() {}\n".to_string());
826
827        let err = coder
828            .apply(&Emission::WholeFiles(files), tmp.path(), &caveats)
829            .unwrap_err();
830        assert!(matches!(
831            err,
832            CoderError::CapabilityDenied {
833                kind: "fs_write",
834                ..
835            }
836        ));
837        // Neither file landed.
838        assert!(!tmp.path().join("a.rs").exists());
839        assert!(!tmp.path().join("b.rs").exists());
840    }
841
842    #[test]
843    fn apply_unified_diff_denied_under_bounded_fs_write() {
844        // We can't enumerate diff paths up front, so any non-`All`
845        // fs_write scope conservatively denies the dispatch.
846        let tmp = TempDir::new().unwrap();
847        let coder = coder_with_no_backend_used();
848        let caveats = Caveats {
849            fs_write: newt_core::Scope::only(["whatever.rs".to_string()]),
850            ..Caveats::top()
851        };
852        let diff = Emission::UnifiedDiff(
853            "--- a/whatever.rs\n+++ b/whatever.rs\n@@ -1 +1 @@\n-x\n+y\n".to_string(),
854        );
855        let err = coder.apply(&diff, tmp.path(), &caveats).unwrap_err();
856        assert!(matches!(
857            err,
858            CoderError::CapabilityDenied {
859                kind: "fs_write",
860                ..
861            }
862        ));
863    }
864
865    // ── host_from_endpoint ───────────────────────────────────────────────
866
867    #[test]
868    fn host_from_endpoint_strips_scheme_and_path() {
869        assert_eq!(
870            super::host_from_endpoint("http://localhost:11434/api/chat"),
871            "localhost"
872        );
873        assert_eq!(
874            super::host_from_endpoint("https://allowed.example.com/v1/chat"),
875            "allowed.example.com"
876        );
877        // No scheme — treated as a bare host.
878        assert_eq!(
879            super::host_from_endpoint("bare.host.local"),
880            "bare.host.local"
881        );
882        // No path, just host:port.
883        assert_eq!(super::host_from_endpoint("http://h:8080"), "h");
884        // Empty path component.
885        assert_eq!(super::host_from_endpoint("https://only.host/"), "only.host");
886    }
887
888    // ── check_call_budget ────────────────────────────────────────────────
889
890    #[test]
891    fn check_call_budget_passes_under_unlimited() {
892        super::check_call_budget(&Caveats::top(), 0).unwrap();
893        super::check_call_budget(&Caveats::top(), 999_999).unwrap();
894    }
895
896    #[test]
897    fn check_call_budget_passes_within_bound() {
898        let caveats = Caveats {
899            max_calls: newt_core::CountBound::AtMost(3),
900            ..Caveats::top()
901        };
902        super::check_call_budget(&caveats, 0).unwrap();
903        super::check_call_budget(&caveats, 2).unwrap();
904    }
905
906    #[test]
907    fn check_call_budget_denies_at_bound() {
908        let caveats = Caveats {
909            max_calls: newt_core::CountBound::AtMost(2),
910            ..Caveats::top()
911        };
912        let err = super::check_call_budget(&caveats, 2).unwrap_err();
913        match err {
914            CoderError::CapabilityDenied { kind, target } => {
915                assert_eq!(kind, "max_calls");
916                assert!(target.contains("#3"));
917            }
918            other => panic!("expected CapabilityDenied, got {other:?}"),
919        }
920    }
921}