Skip to main content

keel_cli/
init.rs

1//! `keel init` — evidence-merged policy generation (dx-spec §1, Level 1).
2//!
3//! Walks the project (static scan, §[`scan`](crate::scan)), merges in observed
4//! traffic from `.keel/discovery.db`, and writes a `keel.toml` that "reads like
5//! a senior SRE reviewed your codebase": every target cites `file:line`
6//! evidence, and observed targets carry their real call counts. The generated
7//! file *is* the documentation — deleting any entry just falls back to the same
8//! built-in defaults.
9//!
10//! Determinism (dx-spec §5): no date in the header unless `--stamp`, targets and
11//! sightings sorted, byte-identical output for identical inputs. `--diff`
12//! previews changes against an existing file without writing.
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::path::Path;
16
17use keel_journal::TargetStats;
18use serde::Serialize;
19
20use crate::agents_cli;
21use crate::diff::{ChangeHunk, PolicyOp, PolicyPath, propose};
22use crate::render::to_json;
23use crate::scan::{ScanResult, TargetClass, TargetEvidence};
24use crate::{EXIT_USAGE, Rendered, evidence, scan};
25
26/// Column at which trailing `#` comments begin, when the line is shorter.
27const COMMENT_COL: usize = 37;
28
29/// Options parsed from the `keel init` flags.
30#[derive(Debug, Clone, Copy, Default)]
31pub struct InitOptions {
32    /// Preview changes against an existing `keel.toml` instead of writing.
33    pub diff: bool,
34    /// Stamp today's date into the header (off by default for determinism).
35    pub stamp: bool,
36    /// Drop the Keel section into `AGENTS.md` (dx-spec §5) instead of generating
37    /// a policy — so every future coding-agent session inherits Keel context.
38    pub agents: bool,
39}
40
41/// Marker fencing the Keel-managed region in `AGENTS.md`, so a re-run updates the
42/// section in place (idempotent) instead of appending a duplicate.
43const AGENTS_BEGIN: &str = "<!-- keel:begin -->";
44const AGENTS_END: &str = "<!-- keel:end -->";
45
46/// The concise, agent-facing Keel section (dx-spec §5). Deterministic: no dates
47/// or versions, so an agent can diff it. Bytes are golden-tested. Lives in its
48/// own file (rather than an inline literal) so `packaging/claude-skill/keel/
49/// SKILL.md` and this snippet can both be checked against the same facts
50/// (tool names, `keel.toml`) without one silently drifting from the other —
51/// see `crates/keel-cli/tests/cli.rs`'s skill-consistency test.
52const AGENTS_SNIPPET: &str = include_str!("../templates/agents-snippet.md");
53
54/// The full fenced block written into `AGENTS.md` (begin marker, snippet, end
55/// marker, trailing newline). Public so the golden test can pin its bytes.
56#[must_use]
57pub fn agents_block() -> String {
58    format!("{AGENTS_BEGIN}\n{AGENTS_SNIPPET}\n{AGENTS_END}\n")
59}
60
61/// The machine twin of `--agents`.
62#[derive(Debug, Serialize)]
63struct AgentsReport {
64    already_current: bool,
65    path: String,
66    updated: bool,
67    wrote: bool,
68}
69
70/// `keel init --agents`: create/update the Keel section in `AGENTS.md`. Idempotent
71/// — a marker-fenced region is replaced in place on re-run, so it never appends a
72/// duplicate and reflects the current snippet exactly.
73fn run_agents(project: &Path) -> Rendered {
74    let path = project.join("AGENTS.md");
75    let existing = match std::fs::read_to_string(&path) {
76        Ok(text) => text,
77        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
78        Err(e) => return config_error(&format!("could not read {}: {e}", path.display())),
79    };
80    let block = agents_block();
81    let (new_content, replaced, wrote) = splice_agents_block(&existing, &block);
82    let already_current = !wrote;
83    // `updated` = we replaced an existing region AND its bytes changed; a fresh
84    // create is `wrote` but not `updated`, and a no-op re-run is neither.
85    let updated = wrote && replaced;
86    if wrote && let Err(e) = std::fs::write(&path, &new_content) {
87        return config_error(&format!("could not write {}: {e}", path.display()));
88    }
89    let verb = if already_current {
90        "already current"
91    } else if updated {
92        "updated the Keel section in"
93    } else {
94        "wrote the Keel section to"
95    };
96    let human = format!("keel \u{25b8} {verb} {}", path.display());
97    let report = AgentsReport {
98        already_current,
99        path: path.display().to_string(),
100        updated,
101        wrote,
102    };
103    Rendered::ok(human, to_json(&report))
104}
105
106/// Compute the new `AGENTS.md` content given the existing text and the desired
107/// block. Returns `(content, replaced_existing, needs_write)`. Pure — unit
108/// tested. Replaces a marker-fenced region in place; else appends (or creates).
109fn splice_agents_block(existing: &str, block: &str) -> (String, bool, bool) {
110    if let (Some(start), Some(end_idx)) = (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
111        let end = end_idx + AGENTS_END.len();
112        // Consume a single trailing newline after the end marker so re-splicing
113        // is stable (the block already ends in one).
114        let tail_start = existing[end..].strip_prefix('\n').map_or(end, |_| end + 1);
115        let mut out = String::with_capacity(existing.len());
116        out.push_str(&existing[..start]);
117        out.push_str(block);
118        out.push_str(&existing[tail_start..]);
119        let needs_write = out != existing;
120        return (out, true, needs_write);
121    }
122    if existing.is_empty() {
123        return (block.to_owned(), false, true);
124    }
125    let mut out = existing.to_owned();
126    if !out.ends_with('\n') {
127        out.push('\n');
128    }
129    out.push('\n');
130    out.push_str(block);
131    (out, false, true)
132}
133
134/// The machine twin of a write.
135#[derive(Debug, Serialize)]
136struct WroteReport {
137    gitignore_updated: bool,
138    observed_runs: u32,
139    static_scans: usize,
140    targets: Vec<String>,
141    wrote: String,
142}
143
144/// The machine twin of `--diff`: the target-name summary plus the applyable
145/// forms (dx-spec §5, diffs as the lingua franca) — `patch` for `git apply`,
146/// `changes` for structured consumption.
147#[derive(Debug, Serialize)]
148struct DiffReport {
149    added: Vec<String>,
150    changes: Vec<ChangeHunk>,
151    notes: Vec<String>,
152    patch: String,
153    removed: Vec<String>,
154    unchanged: Vec<String>,
155}
156
157/// Run `keel init` for `project`.
158pub fn run(project: &Path, opts: InitOptions) -> Rendered {
159    if opts.agents {
160        return run_agents(project);
161    }
162    let scan = scan::scan(project);
163    let discovery = match evidence::read_discovery(project) {
164        Ok(d) => d,
165        Err(e) => return config_error(&e),
166    };
167
168    let stamp = opts.stamp.then(today_utc);
169    let content = render_keel_toml(&scan, &discovery, stamp.as_deref());
170    let targets = merged_targets(&scan, &discovery);
171
172    let agents_cli_path = agents_cli_toml_path(project);
173    let toml_path = agents_cli_path
174        .clone()
175        .unwrap_or_else(|| evidence::keel_toml(project));
176    if opts.diff {
177        return diff(&toml_path, &scan, &discovery, &targets, stamp.as_deref());
178    }
179    if toml_path.exists() {
180        return config_error(&format!(
181            "{} already exists. Run `keel init --diff` to preview changes, or edit it directly.",
182            toml_path.display()
183        ));
184    }
185
186    if let Err(e) = std::fs::write(&toml_path, &content) {
187        return config_error(&format!("could not write {}: {e}", toml_path.display()));
188    }
189    // Only note the agents-cli redirection once the write has actually
190    // happened — not on the `--diff` preview path above, and not on the
191    // refuse-if-exists error path just above that (neither one modifies
192    // anything, so neither should print a note about what would be written).
193    if agents_cli_path.is_some() {
194        eprintln!(
195            "keel \u{25b8} agents-cli project detected \u{2014} writing {} so it ships in the \
196             container image",
197            relative_display(project, &toml_path)
198        );
199    }
200    let gitignore_updated = update_gitignore(project).unwrap_or(false);
201
202    let mut warnings = String::new();
203    if !scan.python_available && has_python_files(project) {
204        warnings.push_str(
205            "\nkeel \u{25b8} note: python3 was not found; Python files were not scanned.\n",
206        );
207    }
208
209    let observed_runs = u32::from(!discovery.is_empty());
210    let human = format!(
211        "keel \u{25b8} wrote {} ({} target{}) from {} static scan{} + {} observed run{}.{}",
212        toml_path.display(),
213        targets.len(),
214        plural(targets.len()),
215        scan.files_scanned,
216        plural(scan.files_scanned),
217        observed_runs,
218        plural(observed_runs as usize),
219        if warnings.is_empty() {
220            String::new()
221        } else {
222            warnings
223        }
224    );
225    let report = WroteReport {
226        gitignore_updated,
227        observed_runs,
228        static_scans: scan.files_scanned,
229        targets,
230        wrote: toml_path.display().to_string(),
231    };
232    Rendered::ok(human, to_json(&report))
233}
234
235/// When `project` is inside a Google `agents-cli` layout (an
236/// `agents-cli-manifest.yaml` naming an `agent_directory`) and that agent
237/// directory is itself inside `project`, redirect the generated `keel.toml`
238/// there instead of the project root — the generated Dockerfile only `COPY`s
239/// `pyproject.toml`, `README.md`, `uv.lock*`, and the agent directory into the
240/// image, so a root `keel.toml` would never ship. Pure (no I/O side effects
241/// beyond the two `canonicalize` calls) — the caller decides whether and when
242/// to print a redirection note; see `run`. Returns `None` for a non-agents-cli
243/// project, or one whose agent directory resolves outside `project`.
244///
245/// The containment check canonicalizes both sides before comparing rather
246/// than using `Path::starts_with` directly: `starts_with` is purely
247/// component-syntactic, so `<project>/../elsewhere` lexically "starts with"
248/// `<project>` even though it resolves outside it. `agents_cli::
249/// find_agents_cli_layout` already rejects any `agent_directory` containing a
250/// `..` component (layer one), so this canonicalizing check (layer two) is
251/// defense in depth against anything that reaches `starts_with` unsanitized —
252/// e.g. an agent directory that is itself a symlink pointing outside
253/// `project`. Both paths are guaranteed to exist by this point (`project` is
254/// the directory `keel init` is running against; `find_agents_cli_layout`
255/// already checked `agent_dir` is a directory), so `canonicalize` failing
256/// here would itself indicate something adversarial (e.g. a TOCTOU removal)
257/// and correctly falls through to `None`.
258fn agents_cli_toml_path(project: &Path) -> Option<std::path::PathBuf> {
259    let layout = agents_cli::find_agents_cli_layout(project)?;
260    let canonical_project = std::fs::canonicalize(project).ok()?;
261    let canonical_agent_dir = std::fs::canonicalize(&layout.agent_dir).ok()?;
262    if !canonical_agent_dir.starts_with(&canonical_project) {
263        return None;
264    }
265    Some(layout.agent_dir.join("keel.toml"))
266}
267
268/// `target` relative to `base` when it is actually nested under `base`, else
269/// the absolute path unchanged. Mirrors `doctor::relative_display`: keeps the
270/// agents-cli redirection note reproducible across checkouts instead of
271/// embedding wherever this particular clone happens to sit on disk.
272fn relative_display(base: &Path, target: &Path) -> String {
273    target.strip_prefix(base).map_or_else(
274        |_| target.display().to_string(),
275        |rel| rel.display().to_string(),
276    )
277}
278
279/// The set of targets the generated file will contain: static findings unioned
280/// with discovery-only targets (runtime caught what the scan missed).
281fn merged_targets(scan: &ScanResult, discovery: &[TargetStats]) -> Vec<String> {
282    let mut set: BTreeSet<String> = scan.targets.keys().cloned().collect();
283    for stats in discovery {
284        set.insert(stats.target.clone());
285    }
286    set.into_iter().collect()
287}
288
289/// Render the full `keel.toml` text. Pure and deterministic — the snapshot
290/// tests pin its bytes.
291pub fn render_keel_toml(
292    scan: &ScanResult,
293    discovery: &[TargetStats],
294    stamp: Option<&str>,
295) -> String {
296    render_keel_toml_for_targets(scan, discovery, &merged_targets(scan, discovery), stamp)
297}
298
299/// Render `keel.toml` text restricted to exactly `targets` (header + one
300/// block per target, in the given order). [`render_keel_toml`] is this called
301/// with every merged target; `--diff`'s no-file-yet case ([`diff`]) calls it
302/// directly with the excluded-bucket hosts already dropped, so the created
303/// file it proposes is byte-identical to a fresh `keel init` minus those
304/// blocks — never a truncated re-render of the full generated text.
305fn render_keel_toml_for_targets(
306    scan: &ScanResult,
307    discovery: &[TargetStats],
308    targets: &[String],
309    stamp: Option<&str>,
310) -> String {
311    let by_target: BTreeMap<&str, &TargetStats> =
312        discovery.iter().map(|s| (s.target.as_str(), s)).collect();
313    let observed_runs = u32::from(!discovery.is_empty());
314
315    let mut out = String::new();
316    let date = stamp.map_or_else(String::new, |d| format!(" ({d})"));
317    let header = format!(
318        "# Generated by keel init from {} static scan{} + {} observed run{}{}\n",
319        scan.files_scanned,
320        plural(scan.files_scanned),
321        observed_runs,
322        plural(observed_runs as usize),
323        date,
324    );
325    out.push_str(&header);
326    out.push_str(
327        "# Every entry below was found in YOUR code. Delete anything; defaults still apply.\n",
328    );
329
330    for target in targets {
331        out.push('\n');
332        let evidence = scan.targets.get(target);
333        let stats = by_target.get(target.as_str()).copied();
334        out.push_str(&render_target_block(target, evidence, stats));
335    }
336    out
337}
338
339/// Render one `[target."…"]` block (no leading blank line): header + evidence
340/// comment(s) + policy body. Shared by the full render and the `--diff` add
341/// hunks, so an added block in the patch is byte-identical to what a fresh
342/// `keel init` would write.
343fn render_target_block(
344    target: &str,
345    evidence: Option<&TargetEvidence>,
346    stats: Option<&TargetStats>,
347) -> String {
348    let mut buf = String::new();
349    let out = &mut buf;
350    let header = format!("[target.\"{target}\"]");
351    let seen_comment = evidence.map(|e| {
352        let labels = e
353            .sightings
354            .iter()
355            .map(scan::Sighting::label)
356            .collect::<Vec<_>>()
357            .join(", ");
358        format!("# seen in: {labels}")
359    });
360    let comment =
361        seen_comment.unwrap_or_else(|| "# seen only at runtime (.keel/discovery.db)".to_owned());
362    out.push_str(&pad_comment(&header, &comment));
363    out.push('\n');
364
365    if let Some(s) = stats {
366        let observed = format!("# {}\n", observed_comment(s));
367        out.push_str(&observed);
368    }
369
370    let class = evidence.map_or_else(
371        || {
372            if target.starts_with("llm:") {
373                TargetClass::Llm
374            } else {
375                TargetClass::Host
376            }
377        },
378        |e| e.class,
379    );
380    match (class, stats) {
381        // dx-spec §1 flagship: an observed `llm:*` target earns an *active* rate
382        // limit tuned from its own evidence, inserted between breaker and cache.
383        (TargetClass::Llm, Some(s)) => {
384            out.push_str(LLM_BODY_HEAD);
385            out.push_str(&observed_rate_line(s));
386            out.push('\n');
387            out.push_str(LLM_CACHE_LINE);
388        }
389        // Host targets stay comments-only even with observed traffic: imposing an
390        // active throttle on general outbound HTTP without an explicit opt-in
391        // would be a Level-0 surprise (dx-spec §1 hard rules). An evidence-tuned
392        // host rate is deliberately out of scope for v0.1.
393        _ => out.push_str(&policy_body(class)),
394    }
395    buf
396}
397
398/// Outbound-host policy body. Mirrors the frozen smart-defaults pack
399/// (`contracts/defaults.toml` outbound); a test asserts they stay in sync.
400const HOST_BODY: &str = concat!(
401    "timeout = \"30s\"\n",
402    "retry   = { attempts = 3, schedule = \"exp(200ms, x2, max 30s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
403    "breaker = { failures = 5, cooldown = \"15s\" }\n",
404);
405
406/// The LLM body up to and including the breaker line — everything that precedes
407/// the *optional* evidence-derived `rate` line. Mirrors `contracts/defaults.toml`
408/// llm pack.
409const LLM_BODY_HEAD: &str = concat!(
410    "timeout = \"120s\"\n",
411    "retry   = { attempts = 6, schedule = \"exp(500ms, x2, max 60s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
412    "breaker = { failures = 5, cooldown = \"30s\" }\n",
413);
414
415/// The LLM dev-cache line — always the last line of an `llm:*` block.
416const LLM_CACHE_LINE: &str =
417    "cache   = { mode = \"dev\" }          # dev-loop cache; disabled when KEEL_ENV=prod\n";
418
419/// Floor for an observed `llm:*` target's active rate, in calls/min. Below this
420/// the derived headroom is noise (LLM traffic is bursty), so we never emit an
421/// active limit under 60/min — also the value used when the observation window
422/// is a single instant (no mean to derive).
423const LLM_RATE_FLOOR_PER_MIN: u64 = 60;
424
425/// Headroom multiplier over the observed MEAN rate. The discovery store keeps
426/// only `calls` + `first_seen_ms`/`last_seen_ms` — it measures a mean, never a
427/// per-minute *peak* — so we scale the mean up generously to leave room for the
428/// peaks we did not measure. NEVER describe the result as a peak.
429const LLM_RATE_HEADROOM: u64 = 3;
430
431/// The policy body for a class *without* any evidence-derived keys. Values
432/// mirror the frozen smart-defaults pack (`contracts/defaults.toml`); a test
433/// asserts they stay in sync. Writing them out (rather than relying on the
434/// invisible defaults) makes the file self-documenting — the DX promise that
435/// "the generated file is the docs".
436fn policy_body(class: TargetClass) -> String {
437    match class {
438        TargetClass::Host => HOST_BODY.to_owned(),
439        TargetClass::Llm => format!("{LLM_BODY_HEAD}{LLM_CACHE_LINE}"),
440    }
441}
442
443/// The observed MEAN calls/minute as an integer floor, or `0` when the window is
444/// a single instant (`first_seen_ms == last_seen_ms`). Pure integer math keeps
445/// the output byte-deterministic. Basis for both the derived rate and its
446/// comment.
447fn mean_per_min_floor(s: &TargetStats) -> u64 {
448    let span_ms = u64::try_from((s.last_seen_ms - s.first_seen_ms).max(0)).unwrap_or(u64::MAX);
449    if span_ms == 0 {
450        return 0;
451    }
452    let calls = u64::try_from(s.calls.max(0)).unwrap_or(u64::MAX);
453    calls.saturating_mul(60_000) / span_ms
454}
455
456/// Derive an active per-minute rate limit for an observed `llm:*` target:
457/// `mean × LLM_RATE_HEADROOM`, [rounded up to a clean value](round_up_clean),
458/// clamped to a floor of [`LLM_RATE_FLOOR_PER_MIN`]. A single-instant window has
459/// no derivable mean, so it falls back to the floor. Deterministic integer math.
460fn llm_rate_per_min(s: &TargetStats) -> u64 {
461    let mean = mean_per_min_floor(s);
462    if mean == 0 {
463        return LLM_RATE_FLOOR_PER_MIN;
464    }
465    round_up_clean(mean.saturating_mul(LLM_RATE_HEADROOM)).max(LLM_RATE_FLOOR_PER_MIN)
466}
467
468/// Round `n` UP to the next "clean" value in the 1-2-5 decade series
469/// (…10, 20, 50, 100, 200, 500, 1000…) — the standard nice-number ceiling.
470/// `round_up_clean(0) == 0`.
471fn round_up_clean(n: u64) -> u64 {
472    if n == 0 {
473        return 0;
474    }
475    let mut unit = 1_u64;
476    loop {
477        for m in [1_u64, 2, 5] {
478            let candidate = m.saturating_mul(unit);
479            if candidate >= n {
480                return candidate;
481            }
482        }
483        match unit.checked_mul(10) {
484            Some(next) => unit = next,
485            None => return u64::MAX,
486        }
487    }
488}
489
490/// The active `rate` line for an observed `llm:*` target, comment-aligned like
491/// the rest of the block. Honest about what we measured: it cites the mean,
492/// never a peak.
493fn observed_rate_line(s: &TargetStats) -> String {
494    let mean = mean_per_min_floor(s);
495    let comment = if mean == 0 {
496        "# floor: single observation window, no mean to derive".to_owned()
497    } else {
498        format!("# headroom over your observed mean of ~{mean}/min")
499    };
500    pad_comment(
501        &format!("rate    = \"{}/min\"", llm_rate_per_min(s)),
502        &comment,
503    )
504}
505
506/// The observed-traffic comment for a target with discovery evidence.
507fn observed_comment(s: &TargetStats) -> String {
508    format!(
509        "observed: {} call{}, {} retr{}, ~{:.1}/min mean (.keel/discovery.db)",
510        s.calls,
511        plural(usize::try_from(s.calls).unwrap_or(usize::MAX)),
512        s.retries,
513        if s.retries == 1 { "y" } else { "ies" },
514        per_minute(s),
515    )
516}
517
518/// Mean calls/minute over the observed window; falls back to the raw call count
519/// when the window has zero span (a single observation).
520fn per_minute(s: &TargetStats) -> f64 {
521    #[expect(
522        clippy::cast_precision_loss,
523        reason = "call counts and ms spans are small; f64 is exact enough for a comment"
524    )]
525    let (calls, span_ms) = (
526        s.calls as f64,
527        (s.last_seen_ms - s.first_seen_ms).max(0) as f64,
528    );
529    if span_ms <= 0.0 {
530        calls
531    } else {
532        calls * 60_000.0 / span_ms
533    }
534}
535
536/// Pad `line` so a trailing `#` comment starts at [`COMMENT_COL`] (or one space
537/// past a longer line), keeping comment columns aligned and deterministic.
538fn pad_comment(line: &str, comment: &str) -> String {
539    let width = if line.len() < COMMENT_COL {
540        COMMENT_COL
541    } else {
542        line.len() + 1
543    };
544    format!("{line:<width$}{comment}")
545}
546
547/// WS3 proposal annotations: what becomes deletable once the proposed
548/// targets are wrapped. One note per simplification sighting attributed to a
549/// target this diff proposes (already sorted — `scan.simplifications` is
550/// (file, line, kind)-ordered), plus one pre-existing-resilience note when
551/// the project imports a resilience library alongside at least one lib Keel
552/// wraps (the same compounding gate as doctor's `preexisting-resilience`
553/// finding, via [`crate::doctor::registry_libs`]).
554fn diff_notes(scan: &ScanResult, added: &[String]) -> Vec<String> {
555    let mut notes = Vec::new();
556    let added_set: BTreeSet<&str> = added.iter().map(String::as_str).collect();
557    for s in &scan.simplifications {
558        if s.targets.iter().any(|t| added_set.contains(t.as_str())) {
559            notes.push(format!(
560                "once wrapped: {} in `{}` at {}:{} becomes redundant (target {})",
561                s.kind,
562                s.function,
563                s.file,
564                s.line,
565                s.targets.join(", ")
566            ));
567        }
568    }
569    // Deliberately ignores `added`: this mirrors doctor's `resilience_finding`,
570    // which fires on ANY compounding lib currently in the project regardless
571    // of what this particular `--diff` newly proposes — a resilience library
572    // that already compounds with an already-wrapped target is exactly as
573    // real a concern as one that compounds with a target this diff adds. Not
574    // a bug; if this proves noisy in practice (e.g. a no-op `--diff` on an
575    // already-fully-configured project still emitting the note), reconsider
576    // gating it on `added` then — but don't "fix" it without that signal.
577    let registry_libs = crate::doctor::registry_libs();
578    if !scan.resilience_libs.is_empty()
579        && scan.libs.iter().any(|l| registry_libs.contains(l.as_str()))
580    {
581        let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
582        notes.push(format!(
583            "pre-existing resilience: this project imports {} — once Keel wraps the same calls, \
584             delete the old retry/backoff or scope Keel's policy (see `keel doctor`)",
585            libs.join(", ")
586        ));
587    }
588    notes
589}
590
591/// The trailing `# excluded (dependency-averse): …` and `# note: …` sections
592/// of the `--diff` human text — split out of [`diff`] to keep that function
593/// under clippy's line-count gate.
594fn render_diff_trailer(excluded: &[crate::doctor::TopologyEntry], notes: &[String]) -> String {
595    let mut out = String::new();
596    if !excluded.is_empty() {
597        // Sorted by host: `TopologyEntry`s already arrive in host order
598        // (`classify_topology` iterates `scan.targets`, a `BTreeMap`), but
599        // sort explicitly so this stays correct even if that internal detail
600        // ever changes.
601        let mut excluded: Vec<_> = excluded.iter().collect();
602        excluded.sort_by(|a, b| a.host.cmp(&b.host));
603        out.push('\n');
604        for entry in excluded {
605            let line = format!(
606                "# excluded (dependency-averse): {} — {}\n",
607                entry.host, entry.reason
608            );
609            out.push_str(&line);
610        }
611    }
612    if !notes.is_empty() {
613        out.push('\n');
614        for note in notes {
615            let line = format!("# note: {note}\n");
616            out.push_str(&line);
617        }
618    }
619    out
620}
621
622/// `--diff`: what `keel init` would add/remove, as a target-name summary *and*
623/// an applyable patch (dx-spec §5, diffs as the lingua franca). Adds append
624/// whole evidence-cited blocks; removes drop `[target."…"]` tables no longer
625/// found in code; targets present on both sides are never touched, so user
626/// tuning and comments outside the changed blocks survive byte-for-byte. With
627/// no existing file the patch creates the whole generated keel.toml
628/// (`--- /dev/null`).
629///
630/// Never proposes a NEW policy block for a host [`doctor::classify_topology`]
631/// puts in the excluded (dependency-averse) bucket — the same classification
632/// `keel doctor` reports, reused directly so the two surfaces never disagree
633/// about which hosts get policy proposed (dx-spec §2's honesty triad). An
634/// excluded host the user already declared in their own `keel.toml` is left
635/// alone (neither added nor removed); the diff's human text explains every
636/// exclusion, sorted by host.
637fn diff(
638    toml_path: &Path,
639    scan: &ScanResult,
640    discovery: &[TargetStats],
641    generated: &[String],
642    stamp: Option<&str>,
643) -> Rendered {
644    let existing_text = match read_existing(toml_path) {
645        Ok(t) => t,
646        Err(e) => return config_error(&e),
647    };
648    let existing = match existing_text
649        .as_deref()
650        .map(|text| existing_targets(text, toml_path))
651        .transpose()
652    {
653        Ok(set) => set.unwrap_or_default(),
654        Err(e) => return config_error(&e),
655    };
656
657    let wrapped_targets: BTreeSet<String> = discovery.iter().map(|s| s.target.clone()).collect();
658    // `--diff` only reads `topology.excluded` below, never `external_processes`
659    // — an empty match table is correct, not a shortcut (see
660    // `classify_topology`'s doc for why cross-referencing here would be dead
661    // work).
662    let topology = crate::doctor::classify_topology(scan, &wrapped_targets, &BTreeMap::new());
663    let excluded_hosts: BTreeSet<&str> =
664        topology.excluded.iter().map(|e| e.host.as_str()).collect();
665
666    let generated_set: BTreeSet<&str> = generated.iter().map(String::as_str).collect();
667    let added: Vec<String> = generated_set
668        .iter()
669        .filter(|t| !existing.contains(**t) && !excluded_hosts.contains(**t))
670        .map(|t| (*t).to_owned())
671        .collect();
672    let removed: Vec<String> = existing
673        .iter()
674        .filter(|t| !generated_set.contains(t.as_str()))
675        .cloned()
676        .collect();
677    let unchanged: Vec<String> = generated_set
678        .iter()
679        .filter(|t| existing.contains(**t))
680        .map(|t| (*t).to_owned())
681        .collect();
682    let notes = diff_notes(scan, &added);
683
684    let ops = if existing_text.is_none() {
685        // No file yet: the patch creates the generated keel.toml (header
686        // comments included), restricted to `added` — which already excludes
687        // dependency-averse-only hosts.
688        vec![PolicyOp::AppendBlock {
689            text: render_keel_toml_for_targets(scan, discovery, &added, stamp),
690        }]
691    } else {
692        let by_target: BTreeMap<&str, &TargetStats> =
693            discovery.iter().map(|s| (s.target.as_str(), s)).collect();
694        let mut ops: Vec<PolicyOp> = removed
695            .iter()
696            .map(|t| PolicyOp::Remove {
697                path: PolicyPath::new(["target", t.as_str()]),
698            })
699            .collect();
700        ops.extend(added.iter().map(|t| PolicyOp::AppendBlock {
701            text: render_target_block(t, scan.targets.get(t), by_target.get(t.as_str()).copied()),
702        }));
703        ops
704    };
705    let proposal = match propose(existing_text.as_deref(), &ops) {
706        Ok(p) => p,
707        Err(e) => return config_error(&e.to_string()),
708    };
709
710    let mut human = String::from("keel \u{25b8} keel init --diff\n");
711    if added.is_empty() && removed.is_empty() {
712        if topology.excluded.is_empty() {
713            human.push_str("  no changes: every discovered target is already in keel.toml.\n");
714        } else {
715            human.push_str(
716                "  no policy changes: every discovered target is already in keel.toml or excluded below.\n",
717            );
718        }
719    } else {
720        for t in &added {
721            let line = format!("  + [target.\"{t}\"]   (found in code, not in keel.toml)\n");
722            human.push_str(&line);
723        }
724        for t in &removed {
725            let line = format!("  - [target.\"{t}\"]   (in keel.toml, no longer found in code)\n");
726            human.push_str(&line);
727        }
728    }
729    if !proposal.patch.is_empty() {
730        human.push_str("\napply with `git apply` (or `patch -p1`):\n\n");
731        human.push_str(&proposal.patch);
732    }
733    human.push_str(&render_diff_trailer(&topology.excluded, &notes));
734    let report = DiffReport {
735        added,
736        changes: proposal.changes,
737        notes,
738        patch: proposal.patch,
739        removed,
740        unchanged,
741    };
742    Rendered::ok(human, to_json(&report))
743}
744
745/// The current `keel.toml` text; `None` when the file does not exist (which
746/// selects the `/dev/null` creation patch).
747fn read_existing(toml_path: &Path) -> Result<Option<String>, String> {
748    if !toml_path.exists() {
749        return Ok(None);
750    }
751    std::fs::read_to_string(toml_path)
752        .map(Some)
753        .map_err(|e| format!("could not read {}: {e}", toml_path.display()))
754}
755
756/// The set of `[target."…"]` keys declared in an existing `keel.toml`.
757fn existing_targets(text: &str, toml_path: &Path) -> Result<BTreeSet<String>, String> {
758    let value: toml::Value = text
759        .parse()
760        .map_err(|e| format!("{} is not valid TOML: {e}", toml_path.display()))?;
761    let mut set = BTreeSet::new();
762    if let Some(table) = value.get("target").and_then(toml::Value::as_table) {
763        for key in table.keys() {
764            set.insert(key.clone());
765        }
766    }
767    Ok(set)
768}
769
770/// Append `.keel/` to `.gitignore` (creating it if absent) when not already
771/// ignored. Returns whether the file was changed.
772fn update_gitignore(project: &Path) -> std::io::Result<bool> {
773    let path = project.join(".gitignore");
774    if !path.exists() {
775        std::fs::write(&path, ".keel/\n")?;
776        return Ok(true);
777    }
778    let text = std::fs::read_to_string(&path)?;
779    let ignored = text
780        .lines()
781        .map(str::trim)
782        .any(|l| l == ".keel" || l == ".keel/");
783    if ignored {
784        return Ok(false);
785    }
786    let mut updated = text;
787    if !updated.ends_with('\n') && !updated.is_empty() {
788        updated.push('\n');
789    }
790    updated.push_str(".keel/\n");
791    std::fs::write(&path, updated)?;
792    Ok(true)
793}
794
795/// Whether `project` contains any `.py` file — used to distinguish "python3
796/// was not found" from "there was nothing to scan" in both `init` and `flows
797/// suggest`'s warnings.
798pub(crate) fn has_python_files(project: &Path) -> bool {
799    let mut found = Vec::new();
800    scan::collect_files(project, &["py"], &mut found);
801    !found.is_empty()
802}
803
804/// A config/usage failure (exit 2), rendered for both audiences.
805fn config_error(message: &str) -> Rendered {
806    #[derive(Serialize)]
807    struct ErrReport<'a> {
808        code: &'static str,
809        error: &'a str,
810    }
811    let human = format!("keel \u{25b8} KEEL-E001: {message}");
812    Rendered {
813        human,
814        json: to_json(&ErrReport {
815            code: "KEEL-E001",
816            error: message,
817        }),
818        exit: EXIT_USAGE,
819        to_stderr: true,
820    }
821    .with_exit(EXIT_USAGE)
822}
823
824/// `"s"` unless `n == 1` — shared by every report that pluralizes a count noun.
825pub(crate) fn plural(n: usize) -> &'static str {
826    if n == 1 { "" } else { "s" }
827}
828
829/// Today's date as `YYYY-MM-DD` (UTC). Only reached under `--stamp`, so the
830/// determinism guarantee (no wall clock by default) holds. Civil-date math is
831/// Hinnant's algorithm — no dependency, no locale.
832fn today_utc() -> String {
833    let secs = std::time::SystemTime::now()
834        .duration_since(std::time::UNIX_EPOCH)
835        .map_or(0, |d| d.as_secs());
836    let days = i64::try_from(secs / 86_400).unwrap_or(0);
837    let (y, m, d) = civil_from_days(days);
838    format!("{y:04}-{m:02}-{d:02}")
839}
840
841/// Convert days-since-epoch to `(year, month, day)` (proleptic Gregorian).
842fn civil_from_days(z: i64) -> (i64, u32, u32) {
843    let z = z + 719_468;
844    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
845    let doe = z - era * 146_097;
846    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
847    let y = yoe + era * 400;
848    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
849    let mp = (5 * doy + 2) / 153;
850    let d = doy - (153 * mp + 2) / 5 + 1;
851    let m = if mp < 10 { mp + 3 } else { mp - 9 };
852    let y = if m <= 2 { y + 1 } else { y };
853    #[expect(
854        clippy::cast_sign_loss,
855        clippy::cast_possible_truncation,
856        reason = "m,d in 1..=31"
857    )]
858    (y, m as u32, d as u32)
859}
860
861#[cfg(test)]
862mod tests {
863    use std::fs;
864    use std::process::{Command, Stdio};
865
866    use keel_journal::ErrorClass;
867    use tempfile::TempDir;
868
869    use super::*;
870
871    /// Whether `python3` is on PATH — gates the Python-scan end-to-end tests,
872    /// mirroring `scan::python`'s test helper (private to that module, so
873    /// duplicated here rather than shared across crates).
874    fn python3_present() -> bool {
875        Command::new("python3")
876            .arg("--version")
877            .stdout(Stdio::null())
878            .stderr(Stdio::null())
879            .status()
880            .is_ok_and(|s| s.success())
881    }
882
883    /// A `TargetStats` for `llm:openai` with the given call count and observation
884    /// window; every other counter is inert (irrelevant to rate derivation).
885    fn llm_stats(calls: i64, first_seen_ms: i64, last_seen_ms: i64) -> TargetStats {
886        TargetStats {
887            target: "llm:openai".to_owned(),
888            calls,
889            attempts: calls,
890            retries: 0,
891            successes: calls,
892            failures: 0,
893            cache_hits: 0,
894            throttled: 0,
895            breaker_opens: 0,
896            total_latency_ms: 0,
897            max_latency_ms: 0,
898            first_seen_ms,
899            last_seen_ms,
900            last_error_class: None,
901            last_error_status: None,
902            not_retried: 0,
903            unwrapped_calls: 0,
904        }
905    }
906
907    fn host_scan() -> ScanResult {
908        let mut s = ScanResult {
909            files_scanned: 2,
910            python_available: true,
911            ..ScanResult::default()
912        };
913        // Reuse the private add via a fresh evidence set.
914        s.targets.insert(
915            "api.example.com".to_owned(),
916            TargetEvidence {
917                class: TargetClass::Host,
918                sightings: [scan::Sighting {
919                    file: "app.py".into(),
920                    line: 4,
921                }]
922                .into_iter()
923                .collect(),
924            },
925        );
926        s.targets.insert(
927            "llm:openai".to_owned(),
928            TargetEvidence {
929                class: TargetClass::Llm,
930                sightings: [scan::Sighting {
931                    file: "app.py".into(),
932                    line: 2,
933                }]
934                .into_iter()
935                .collect(),
936            },
937        );
938        s
939    }
940
941    #[test]
942    fn header_counts_and_no_date_by_default() {
943        let out = render_keel_toml(&host_scan(), &[], None);
944        assert!(
945            out.starts_with("# Generated by keel init from 2 static scans + 0 observed runs\n")
946        );
947        assert!(!out.contains("202"), "no date without --stamp");
948    }
949
950    #[test]
951    fn stamp_adds_a_date() {
952        let out = render_keel_toml(&host_scan(), &[], Some("2026-07-12"));
953        assert!(out.lines().next().unwrap().ends_with(" (2026-07-12)"));
954    }
955
956    #[test]
957    fn host_and_llm_blocks_cite_evidence() {
958        let out = render_keel_toml(&host_scan(), &[], None);
959        assert!(out.contains("[target.\"api.example.com\"]"));
960        assert!(out.contains("# seen in: app.py:4"));
961        assert!(out.contains("[target.\"llm:openai\"]"));
962        assert!(out.contains("# seen in: app.py:2"));
963        assert!(out.contains("cache   = { mode = \"dev\" }"));
964    }
965
966    #[test]
967    fn discovery_only_target_is_surfaced_with_observed_comment() {
968        let scan = ScanResult {
969            files_scanned: 1,
970            python_available: true,
971            ..ScanResult::default()
972        };
973        let stats = TargetStats {
974            target: "api.dynamic.com".to_owned(),
975            calls: 120,
976            attempts: 132,
977            retries: 12,
978            successes: 120,
979            failures: 0,
980            cache_hits: 0,
981            throttled: 0,
982            breaker_opens: 0,
983            total_latency_ms: 12_000,
984            max_latency_ms: 300,
985            first_seen_ms: 0,
986            last_seen_ms: 120_000, // 2 minutes → 60/min
987            last_error_class: None,
988            last_error_status: None,
989            not_retried: 0,
990            unwrapped_calls: 0,
991        };
992        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
993        assert!(out.contains("[target.\"api.dynamic.com\"]"));
994        assert!(out.contains("# seen only at runtime (.keel/discovery.db)"));
995        assert!(out.contains("# observed: 120 calls, 12 retries, ~60.0/min mean"));
996        // header now reports one observed run
997        assert!(out.contains("+ 1 observed run\n"));
998    }
999
1000    #[test]
1001    fn error_class_import_is_available() {
1002        // Guards the keel_journal re-export used by status/doctor tests too.
1003        let _ = ErrorClass::Http;
1004    }
1005
1006    #[test]
1007    fn default_body_matches_the_frozen_pack() {
1008        // The hardcoded policy bodies must equal contracts/defaults.toml.
1009        let defaults: toml::Value = include_str!("../contract/defaults.toml")
1010            .parse()
1011            .expect("defaults.toml parses");
1012        let outbound = &defaults["defaults"]["outbound"];
1013        assert_eq!(outbound["timeout"].as_str(), Some("30s"));
1014        assert_eq!(outbound["retry"]["attempts"].as_integer(), Some(3));
1015        assert_eq!(outbound["breaker"]["cooldown"].as_str(), Some("15s"));
1016        let llm = &defaults["defaults"]["llm"];
1017        assert_eq!(llm["timeout"].as_str(), Some("120s"));
1018        assert_eq!(llm["retry"]["attempts"].as_integer(), Some(6));
1019        assert_eq!(llm["breaker"]["cooldown"].as_str(), Some("30s"));
1020        assert_eq!(llm["cache"]["mode"].as_str(), Some("dev"));
1021        // and the bodies we emit reflect those values
1022        assert!(policy_body(TargetClass::Host).contains("attempts = 3"));
1023        assert!(policy_body(TargetClass::Host).contains("cooldown = \"15s\""));
1024        assert!(policy_body(TargetClass::Llm).contains("attempts = 6"));
1025        assert!(policy_body(TargetClass::Llm).contains("cooldown = \"30s\""));
1026        assert!(policy_body(TargetClass::Llm).contains("mode = \"dev\""));
1027    }
1028
1029    #[test]
1030    fn civil_date_epoch_is_1970_01_01() {
1031        assert_eq!(civil_from_days(0), (1970, 1, 1));
1032        assert_eq!(civil_from_days(19_997), (2024, 10, 1));
1033    }
1034
1035    // ---- item 3: evidence-tuned llm rate derivation ----
1036
1037    #[test]
1038    fn round_up_clean_walks_the_1_2_5_series() {
1039        assert_eq!(round_up_clean(0), 0);
1040        assert_eq!(round_up_clean(1), 1);
1041        assert_eq!(round_up_clean(3), 5);
1042        assert_eq!(round_up_clean(6), 10);
1043        assert_eq!(round_up_clean(11), 20);
1044        assert_eq!(round_up_clean(50), 50);
1045        assert_eq!(round_up_clean(60), 100);
1046        assert_eq!(round_up_clean(123), 200);
1047        assert_eq!(round_up_clean(300), 500);
1048        assert_eq!(round_up_clean(501), 1_000);
1049    }
1050
1051    #[test]
1052    fn llm_rate_is_mean_times_three_rounded_up_to_a_clean_value() {
1053        // 200 calls over a 2-min window → mean 100/min → ×3 = 300 → clean 500.
1054        let s = llm_stats(200, 0, 120_000);
1055        assert_eq!(mean_per_min_floor(&s), 100);
1056        assert_eq!(llm_rate_per_min(&s), 500);
1057    }
1058
1059    #[test]
1060    fn llm_rate_floors_at_60_for_sparse_traffic() {
1061        // 5 calls over 1 min → mean 5/min → ×3 = 15 → clean 20 → floored to 60.
1062        let s = llm_stats(5, 0, 60_000);
1063        assert_eq!(mean_per_min_floor(&s), 5);
1064        assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
1065    }
1066
1067    #[test]
1068    fn llm_rate_zero_span_window_falls_back_to_floor() {
1069        // Single-instant window (first_seen == last_seen): no mean derivable.
1070        let s = llm_stats(500, 1_000, 1_000);
1071        assert_eq!(mean_per_min_floor(&s), 0);
1072        assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
1073    }
1074
1075    #[test]
1076    fn observed_llm_target_gets_an_active_rate_line() {
1077        let scan = ScanResult {
1078            files_scanned: 1,
1079            python_available: true,
1080            ..ScanResult::default()
1081        };
1082        let stats = llm_stats(200, 0, 120_000);
1083        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1084
1085        assert!(out.contains("[target.\"llm:openai\"]"));
1086        assert!(out.contains("rate    = \"500/min\""));
1087        assert!(out.contains("# headroom over your observed mean of ~100/min"));
1088        // We measure a mean, never a peak — the word must never appear.
1089        assert!(!out.contains("peak"));
1090        // The rate line sits between breaker and cache.
1091        let rate_at = out.find("rate    =").expect("rate line present");
1092        let cache_at = out.find("cache   =").expect("cache line present");
1093        assert!(rate_at < cache_at, "rate must precede cache");
1094    }
1095
1096    #[test]
1097    fn zero_span_llm_target_emits_floor_with_honest_comment() {
1098        let scan = ScanResult {
1099            files_scanned: 1,
1100            python_available: true,
1101            ..ScanResult::default()
1102        };
1103        let stats = llm_stats(9, 5_000, 5_000);
1104        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1105        assert!(out.contains("rate    = \"60/min\""));
1106        assert!(out.contains("# floor: single observation window, no mean to derive"));
1107        assert!(!out.contains("peak"));
1108    }
1109
1110    #[test]
1111    fn observed_host_target_stays_comments_only() {
1112        // Host targets never get an active rate, even with observed traffic.
1113        let scan = ScanResult {
1114            files_scanned: 1,
1115            python_available: true,
1116            ..ScanResult::default()
1117        };
1118        let stats = TargetStats {
1119            target: "api.host.example".to_owned(),
1120            ..llm_stats(200, 0, 120_000)
1121        };
1122        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1123        assert!(out.contains("[target.\"api.host.example\"]"));
1124        assert!(
1125            !out.contains("rate    ="),
1126            "host targets must not emit an active rate line"
1127        );
1128    }
1129
1130    // ---- item 2: keel init write path ----
1131
1132    #[test]
1133    fn refuses_when_keel_toml_already_exists() {
1134        let dir = TempDir::new().unwrap();
1135        fs::write(dir.path().join("keel.toml"), "# hand-written\n").unwrap();
1136
1137        let r = run(dir.path(), InitOptions::default());
1138
1139        assert_eq!(r.exit, EXIT_USAGE);
1140        assert!(r.to_stderr);
1141        assert!(r.human.contains("already exists"));
1142        // The existing file is left untouched.
1143        assert_eq!(
1144            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1145            "# hand-written\n"
1146        );
1147    }
1148
1149    // ---- agents-cli layout redirection ----
1150
1151    /// An `agents-cli` project (manifest + agent dir at the root) gets its
1152    /// generated `keel.toml` written into the agent directory, not the
1153    /// project root — the generated Dockerfile only COPYs the agent dir.
1154    #[test]
1155    fn agents_cli_project_writes_keel_toml_into_the_agent_dir() {
1156        let dir = TempDir::new().unwrap();
1157        fs::create_dir(dir.path().join("app")).unwrap();
1158        fs::write(
1159            dir.path().join("agents-cli-manifest.yaml"),
1160            "schema_version: 1\nagent_directory: app\n",
1161        )
1162        .unwrap();
1163
1164        let r = run(dir.path(), InitOptions::default());
1165
1166        assert_eq!(r.exit, crate::EXIT_OK);
1167        assert!(!dir.path().join("keel.toml").exists(), "no root keel.toml");
1168        assert!(
1169            dir.path().join("app").join("keel.toml").exists(),
1170            "keel.toml lands in the agent directory"
1171        );
1172        assert_eq!(
1173            r.json["wrote"].as_str().unwrap(),
1174            dir.path()
1175                .join("app")
1176                .join("keel.toml")
1177                .display()
1178                .to_string()
1179        );
1180    }
1181
1182    /// A project with no `agents-cli-manifest.yaml` is unaffected: `keel.toml`
1183    /// still lands at the project root, byte-identical to the non-agents-cli
1184    /// goldens.
1185    #[test]
1186    fn non_agents_cli_project_writes_keel_toml_at_the_root() {
1187        let dir = TempDir::new().unwrap();
1188
1189        let r = run(dir.path(), InitOptions::default());
1190
1191        assert_eq!(r.exit, crate::EXIT_OK);
1192        assert!(dir.path().join("keel.toml").exists());
1193    }
1194
1195    /// The refuse-if-exists guard applies to the redirected path: an existing
1196    /// `keel.toml` inside the agent directory blocks the write even though the
1197    /// project root has none.
1198    #[test]
1199    fn agents_cli_refuses_when_the_redirected_path_already_exists() {
1200        let dir = TempDir::new().unwrap();
1201        fs::create_dir(dir.path().join("app")).unwrap();
1202        fs::write(
1203            dir.path().join("agents-cli-manifest.yaml"),
1204            "agent_directory: app\n",
1205        )
1206        .unwrap();
1207        fs::write(dir.path().join("app").join("keel.toml"), "# hand-written\n").unwrap();
1208
1209        let r = run(dir.path(), InitOptions::default());
1210
1211        assert_eq!(r.exit, EXIT_USAGE);
1212        assert!(r.human.contains("already exists"));
1213        assert_eq!(
1214            fs::read_to_string(dir.path().join("app").join("keel.toml")).unwrap(),
1215            "# hand-written\n"
1216        );
1217    }
1218
1219    /// `agents_cli_toml_path` itself: `None` for a non-agents-cli project.
1220    #[test]
1221    fn agents_cli_toml_path_is_none_without_a_manifest() {
1222        let dir = TempDir::new().unwrap();
1223        assert!(agents_cli_toml_path(dir.path()).is_none());
1224    }
1225
1226    /// CRITICAL regression: `agent_directory: ../elsewhere` must never escape
1227    /// `project`, even though the sibling directory it names genuinely exists
1228    /// on disk (the reviewer's exact repro). Both layers of the fix apply
1229    /// here — `agents_cli::find_agents_cli_layout` already rejects the `..`
1230    /// component, so `agents_cli_toml_path` sees no layout at all and returns
1231    /// `None` via `?`; this test pins that end-to-end outcome from `init`'s
1232    /// side rather than re-testing `agents_cli`'s parser directly.
1233    #[test]
1234    fn agents_cli_toml_path_is_none_when_agent_directory_escapes_the_project() {
1235        let root = TempDir::new().unwrap();
1236        let project = root.path().join("project");
1237        fs::create_dir(&project).unwrap();
1238        fs::create_dir(root.path().join("elsewhere")).unwrap();
1239        fs::write(
1240            project.join("agents-cli-manifest.yaml"),
1241            "agent_directory: ../elsewhere\n",
1242        )
1243        .unwrap();
1244
1245        assert!(agents_cli_toml_path(&project).is_none());
1246
1247        // And the same repro through the full `run` path never writes
1248        // outside `project`.
1249        let r = run(&project, InitOptions::default());
1250        assert_eq!(r.exit, crate::EXIT_OK);
1251        assert!(project.join("keel.toml").exists());
1252        assert!(!root.path().join("elsewhere").join("keel.toml").exists());
1253    }
1254
1255    #[test]
1256    fn diff_reports_added_and_removed_targets_precisely() {
1257        let dir = TempDir::new().unwrap();
1258        // JS scan (pure Rust, no python3) will find `api.example.com`.
1259        fs::write(
1260            dir.path().join("app.mjs"),
1261            "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1262        )
1263        .unwrap();
1264        // An existing keel.toml declares a target the scan will NOT find.
1265        fs::write(
1266            dir.path().join("keel.toml"),
1267            "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n",
1268        )
1269        .unwrap();
1270
1271        let r = run(
1272            dir.path(),
1273            InitOptions {
1274                diff: true,
1275                stamp: false,
1276                agents: false,
1277            },
1278        );
1279
1280        assert_eq!(r.exit, crate::EXIT_OK);
1281        assert_eq!(
1282            r.json["added"].as_array().unwrap(),
1283            &vec![serde_json::json!("api.example.com")]
1284        );
1285        assert_eq!(
1286            r.json["removed"].as_array().unwrap(),
1287            &vec![serde_json::json!("api.gone.example")]
1288        );
1289        assert!(r.json["unchanged"].as_array().unwrap().is_empty());
1290        assert!(r.human.contains("+ [target.\"api.example.com\"]"));
1291        assert!(r.human.contains("- [target.\"api.gone.example\"]"));
1292        // --diff never writes.
1293        assert_eq!(
1294            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1295            "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n"
1296        );
1297    }
1298
1299    /// dx-spec §5 (diffs as the lingua franca): `--diff` emits an applyable
1300    /// patch. Applying it removes stale blocks and appends evidence-cited new
1301    /// ones while user tuning outside the touched blocks survives byte-for-byte.
1302    #[test]
1303    fn diff_emits_an_applyable_patch_and_structured_changes() {
1304        let dir = TempDir::new().unwrap();
1305        fs::write(
1306            dir.path().join("app.mjs"),
1307            "// two targets, one already in keel.toml\nconst KEPT = await fetch(\"https://api.example.com/v1/x\");\nconst ADDED = await fetch(\"https://api.new.example/v2/y\");\n",
1308        )
1309        .unwrap();
1310        let old = "\
1311# hand-tuned: keep this comment
1312
1313[target.\"api.example.com\"]
1314timeout = \"9s\"   # user tuning survives
1315
1316[target.\"api.gone.example\"]  # stale
1317timeout = \"5s\"
1318";
1319        fs::write(dir.path().join("keel.toml"), old).unwrap();
1320
1321        let r = run(
1322            dir.path(),
1323            InitOptions {
1324                diff: true,
1325                stamp: false,
1326                agents: false,
1327            },
1328        );
1329
1330        assert_eq!(r.exit, crate::EXIT_OK);
1331        let patch = r.json["patch"].as_str().unwrap();
1332        assert!(
1333            patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"),
1334            "{patch}"
1335        );
1336        assert!(r.human.contains("apply with `git apply`"));
1337        assert!(
1338            r.human.contains(patch),
1339            "the human output carries the patch verbatim"
1340        );
1341
1342        let applied = crate::diff::apply_unified(old, patch).unwrap();
1343        let value: toml::Value = applied.parse().expect("applied file parses");
1344        assert!(value["target"].get("api.gone.example").is_none());
1345        assert!(value["target"].get("api.new.example").is_some());
1346        assert!(applied.contains("# hand-tuned: keep this comment"));
1347        assert!(applied.contains("timeout = \"9s\"   # user tuning survives"));
1348        // The added block is byte-identical to what a fresh init would write.
1349        assert!(applied.contains("[target.\"api.new.example\"]"));
1350        assert!(applied.contains("# seen in: app.mjs:3"));
1351
1352        // Structured hunks: one removal, one addition, sorted by path.
1353        let changes = r.json["changes"].as_array().unwrap();
1354        let paths: Vec<&str> = changes
1355            .iter()
1356            .map(|c| c["path"].as_str().unwrap())
1357            .collect();
1358        assert_eq!(
1359            paths,
1360            ["target.\"api.gone.example\"", "target.\"api.new.example\""]
1361        );
1362        assert!(changes[0]["after"].is_null());
1363        assert!(changes[1]["before"].is_null());
1364        // --diff never writes.
1365        assert_eq!(
1366            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1367            old
1368        );
1369    }
1370
1371    /// dx-spec's honesty triad, `--diff` leg: a host seen only inside a
1372    /// dependency-averse file (Task 7's `keel doctor` bucket, reused here via
1373    /// `classify_topology`) must never get a proposed policy block, and the
1374    /// diff must say why — so `keel doctor` and `keel init --diff` never
1375    /// disagree about which hosts get policy proposed.
1376    #[test]
1377    fn diff_skips_dependency_averse_only_hosts_and_says_why() {
1378        if !python3_present() {
1379            eprintln!("skip: python3 not available");
1380            return;
1381        }
1382        let dir = TempDir::new().unwrap();
1383        fs::write(
1384            dir.path().join("risk_gate.py"),
1385            "\"\"\"risk gate. stdlib only.\"\"\"\nimport urllib.request\nU = \"https://api.broker.com/v2\"\n",
1386        )
1387        .unwrap();
1388        fs::write(
1389            dir.path().join("app.py"),
1390            "import httpx\nU = \"https://api.normal.com/v1\"\n",
1391        )
1392        .unwrap();
1393
1394        let r = run(
1395            dir.path(),
1396            InitOptions {
1397                diff: true,
1398                stamp: false,
1399                agents: false,
1400            },
1401        );
1402
1403        assert_eq!(r.exit, crate::EXIT_OK);
1404        let text = &r.human;
1405        assert!(
1406            text.contains("api.normal.com"),
1407            "normal host proposed: {text}"
1408        );
1409        assert!(
1410            !text.contains("[target.\"api.broker.com\"]"),
1411            "no policy for the gate-file host: {text}"
1412        );
1413        assert!(
1414            text.contains("excluded (dependency-averse): api.broker.com"),
1415            "{text}"
1416        );
1417        // The structured `added` list must agree with the human text.
1418        let added = r.json["added"].as_array().unwrap();
1419        assert!(added.iter().any(|v| v == "api.normal.com"));
1420        assert!(!added.iter().any(|v| v == "api.broker.com"));
1421    }
1422
1423    /// WS3: `keel init --diff` annotates proposals with what becomes deletable —
1424    /// hand-rolled patterns attributed to a proposed target, and the
1425    /// pre-existing-resilience signal (today doctor-only) — in both the JSON
1426    /// (`notes`) and the human text (`# note:` lines).
1427    #[test]
1428    fn init_diff_annotates_simplifications_and_preexisting_resilience() {
1429        if !python3_present() {
1430            eprintln!("skip: python3 not available");
1431            return;
1432        }
1433        let dir = TempDir::new().unwrap();
1434        fs::write(
1435            dir.path().join("app.py"),
1436            r#"import time
1437import httpx
1438import tenacity
1439
1440API = "https://api.normal.com/v1"
1441
1442def caller():
1443    n = 0
1444    while True:
1445        try:
1446            return httpx.get(API)
1447        except Exception:
1448            n += 1
1449            time.sleep(1)
1450"#,
1451        )
1452        .unwrap();
1453        let r = run(
1454            dir.path(),
1455            InitOptions {
1456                diff: true,
1457                stamp: false,
1458                agents: false,
1459            },
1460        );
1461        let json = serde_json::to_string(&r.json).unwrap();
1462        assert!(
1463            json.contains("\"notes\""),
1464            "DiffReport carries notes: {json}"
1465        );
1466        assert!(json.contains("hand-rolled-retry"));
1467        assert!(
1468            json.contains("app.py:9"),
1469            "anchored at the while loop: {json}"
1470        );
1471        assert!(json.contains("tenacity"));
1472        assert!(r.human.contains("# note:"));
1473        assert!(r.human.contains("hand-rolled-retry"));
1474        assert!(r.human.contains("tenacity"));
1475    }
1476
1477    /// With no keel.toml the patch creates the whole generated file from
1478    /// `/dev/null`, byte-identical to what `keel init` would write.
1479    #[test]
1480    fn diff_without_existing_file_is_a_dev_null_creation_patch() {
1481        let dir = TempDir::new().unwrap();
1482        fs::write(
1483            dir.path().join("app.mjs"),
1484            "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1485        )
1486        .unwrap();
1487
1488        let r = run(
1489            dir.path(),
1490            InitOptions {
1491                diff: true,
1492                stamp: false,
1493                agents: false,
1494            },
1495        );
1496
1497        assert_eq!(r.exit, crate::EXIT_OK);
1498        let patch = r.json["patch"].as_str().unwrap();
1499        assert!(
1500            patch.starts_with("--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,"),
1501            "{patch}"
1502        );
1503        let scanned = scan::scan(dir.path());
1504        let expected = render_keel_toml(&scanned, &[], None);
1505        assert_eq!(crate::diff::apply_unified("", patch).unwrap(), expected);
1506        assert_eq!(
1507            r.json["added"].as_array().unwrap(),
1508            &vec![serde_json::json!("api.example.com")]
1509        );
1510    }
1511
1512    // ---- keel init --agents ----
1513
1514    #[test]
1515    fn agents_creates_then_is_idempotent() {
1516        let dir = TempDir::new().unwrap();
1517        let opts = InitOptions {
1518            agents: true,
1519            ..InitOptions::default()
1520        };
1521        let r1 = run(dir.path(), opts);
1522        assert_eq!(r1.exit, crate::EXIT_OK);
1523        assert!(r1.json["wrote"].as_bool().unwrap());
1524        let path = dir.path().join("AGENTS.md");
1525        let c1 = fs::read_to_string(&path).unwrap();
1526        assert!(c1.contains("## Keel (resilience & durable execution)"));
1527        assert!(c1.contains("keel doctor --json"));
1528
1529        // Re-run: nothing to change → already current, file byte-identical.
1530        let r2 = run(dir.path(), opts);
1531        assert!(r2.json["already_current"].as_bool().unwrap());
1532        assert_eq!(fs::read_to_string(&path).unwrap(), c1);
1533    }
1534
1535    #[test]
1536    fn splice_appends_then_replaces_region_without_duplicating() {
1537        let block = agents_block();
1538        // Append below existing prose.
1539        let (out, replaced, wrote) = splice_agents_block("# My project\n", &block);
1540        assert!(!replaced && wrote);
1541        assert!(out.starts_with("# My project\n\n"));
1542        assert!(out.contains(AGENTS_BEGIN) && out.contains(AGENTS_END));
1543        // Re-splicing the same block replaces in place and is a no-op write.
1544        let (out2, replaced2, wrote2) = splice_agents_block(&out, &block);
1545        assert!(replaced2 && !wrote2);
1546        assert_eq!(out2, out);
1547        assert_eq!(
1548            out2.matches(AGENTS_BEGIN).count(),
1549            1,
1550            "exactly one Keel block"
1551        );
1552    }
1553
1554    #[test]
1555    fn gitignore_is_created_when_absent() {
1556        let dir = TempDir::new().unwrap();
1557        assert!(update_gitignore(dir.path()).unwrap());
1558        assert_eq!(
1559            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1560            ".keel/\n"
1561        );
1562    }
1563
1564    #[test]
1565    fn gitignore_is_appended_when_keel_line_missing() {
1566        let dir = TempDir::new().unwrap();
1567        // No trailing newline: the appender must add one before `.keel/`.
1568        fs::write(dir.path().join(".gitignore"), "node_modules/\n*.log").unwrap();
1569
1570        assert!(update_gitignore(dir.path()).unwrap());
1571
1572        assert_eq!(
1573            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1574            "node_modules/\n*.log\n.keel/\n"
1575        );
1576    }
1577
1578    #[test]
1579    fn gitignore_is_a_noop_when_already_ignored() {
1580        let dir = TempDir::new().unwrap();
1581        let original = "build/\n.keel/\ncoverage/\n";
1582        fs::write(dir.path().join(".gitignore"), original).unwrap();
1583
1584        assert!(!update_gitignore(dir.path()).unwrap());
1585
1586        assert_eq!(
1587            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1588            original
1589        );
1590    }
1591
1592    /// CRITICAL containment test: `agents_cli_toml_path` rejects an absolute
1593    /// agent directory using canonicalization (layer 2), not Path::starts_with
1594    /// (which is purely syntactic). This is defense in depth: even though a
1595    /// manifest with `agent_directory: /etc` contains no `..` component (so
1596    /// layer 1 in agents_cli.rs does not reject it), `canonicalize` resolves it
1597    /// to the actual `/etc` on disk, which fails the starts_with check and
1598    /// returns None. If layer 2 were removed and only layer 1 remained, this
1599    /// would escape the project.
1600    #[test]
1601    fn agents_cli_toml_path_rejects_absolute_path_agent_directory() {
1602        let root = TempDir::new().unwrap();
1603        let project = root.path().join("project");
1604        fs::create_dir(&project).unwrap();
1605        let outside = root.path().join("outside");
1606        fs::create_dir(&outside).unwrap();
1607
1608        fs::write(
1609            project.join("agents-cli-manifest.yaml"),
1610            // Using the actual outside TempDir path (absolute, no ..) — layer 1
1611            // does NOT reject this because there's no ParentDir component.
1612            format!("agent_directory: {}\n", outside.display()),
1613        )
1614        .unwrap();
1615
1616        // agents_cli_toml_path should return None because canonicalization
1617        // resolves the absolute path and discovers it's outside project.
1618        assert!(agents_cli_toml_path(&project).is_none());
1619
1620        // End-to-end through run: writes to project root, never to outside.
1621        let r = run(&project, InitOptions::default());
1622        assert_eq!(r.exit, crate::EXIT_OK);
1623        assert!(project.join("keel.toml").exists());
1624        assert!(!outside.join("keel.toml").exists());
1625    }
1626
1627    /// CRITICAL containment test: `agents_cli_toml_path` rejects a symlink
1628    /// agent directory that points outside the project. This is defense in
1629    /// depth: the manifest's `agent_directory` is a valid relative path inside
1630    /// `project`, but if it's a symlink pointing outside, `canonicalize` will
1631    /// resolve it to the actual target and fail the starts_with check. Layer 1
1632    /// (agents_cli.rs rejecting `..`) does not catch this — only canonicalization
1633    /// (layer 2) does.
1634    #[cfg(unix)]
1635    #[test]
1636    fn agents_cli_toml_path_rejects_symlink_escape_to_outside_project() {
1637        use std::os::unix::fs as unix_fs;
1638
1639        let root = TempDir::new().unwrap();
1640        let project = root.path().join("project");
1641        fs::create_dir(&project).unwrap();
1642        let outside = root.path().join("outside");
1643        fs::create_dir(&outside).unwrap();
1644
1645        // Create a symlink inside project that points to outside.
1646        let symlink = project.join("app");
1647        unix_fs::symlink(&outside, &symlink).unwrap();
1648
1649        fs::write(
1650            project.join("agents-cli-manifest.yaml"),
1651            "agent_directory: app\n",
1652        )
1653        .unwrap();
1654
1655        // agents_cli_toml_path should return None because canonicalize resolves
1656        // the symlink to outside and fails the starts_with check.
1657        assert!(agents_cli_toml_path(&project).is_none());
1658
1659        // End-to-end through run: writes to project root, never to outside.
1660        let r = run(&project, InitOptions::default());
1661        assert_eq!(r.exit, crate::EXIT_OK);
1662        assert!(project.join("keel.toml").exists());
1663        assert!(!outside.join("keel.toml").exists());
1664    }
1665}