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    let registry_libs = crate::doctor::registry_libs();
570    if !scan.resilience_libs.is_empty()
571        && scan.libs.iter().any(|l| registry_libs.contains(l.as_str()))
572    {
573        let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
574        notes.push(format!(
575            "pre-existing resilience: this project imports {} — once Keel wraps the same calls, \
576             delete the old retry/backoff or scope Keel's policy (see `keel doctor`)",
577            libs.join(", ")
578        ));
579    }
580    notes
581}
582
583/// The trailing `# excluded (dependency-averse): …` and `# note: …` sections
584/// of the `--diff` human text — split out of [`diff`] to keep that function
585/// under clippy's line-count gate.
586fn render_diff_trailer(excluded: &[crate::doctor::TopologyEntry], notes: &[String]) -> String {
587    let mut out = String::new();
588    if !excluded.is_empty() {
589        // Sorted by host: `TopologyEntry`s already arrive in host order
590        // (`classify_topology` iterates `scan.targets`, a `BTreeMap`), but
591        // sort explicitly so this stays correct even if that internal detail
592        // ever changes.
593        let mut excluded: Vec<_> = excluded.iter().collect();
594        excluded.sort_by(|a, b| a.host.cmp(&b.host));
595        out.push('\n');
596        for entry in excluded {
597            let line = format!(
598                "# excluded (dependency-averse): {} — {}\n",
599                entry.host, entry.reason
600            );
601            out.push_str(&line);
602        }
603    }
604    if !notes.is_empty() {
605        out.push('\n');
606        for note in notes {
607            let line = format!("# note: {note}\n");
608            out.push_str(&line);
609        }
610    }
611    out
612}
613
614/// `--diff`: what `keel init` would add/remove, as a target-name summary *and*
615/// an applyable patch (dx-spec §5, diffs as the lingua franca). Adds append
616/// whole evidence-cited blocks; removes drop `[target."…"]` tables no longer
617/// found in code; targets present on both sides are never touched, so user
618/// tuning and comments outside the changed blocks survive byte-for-byte. With
619/// no existing file the patch creates the whole generated keel.toml
620/// (`--- /dev/null`).
621///
622/// Never proposes a NEW policy block for a host [`doctor::classify_topology`]
623/// puts in the excluded (dependency-averse) bucket — the same classification
624/// `keel doctor` reports, reused directly so the two surfaces never disagree
625/// about which hosts get policy proposed (dx-spec §2's honesty triad). An
626/// excluded host the user already declared in their own `keel.toml` is left
627/// alone (neither added nor removed); the diff's human text explains every
628/// exclusion, sorted by host.
629fn diff(
630    toml_path: &Path,
631    scan: &ScanResult,
632    discovery: &[TargetStats],
633    generated: &[String],
634    stamp: Option<&str>,
635) -> Rendered {
636    let existing_text = match read_existing(toml_path) {
637        Ok(t) => t,
638        Err(e) => return config_error(&e),
639    };
640    let existing = match existing_text
641        .as_deref()
642        .map(|text| existing_targets(text, toml_path))
643        .transpose()
644    {
645        Ok(set) => set.unwrap_or_default(),
646        Err(e) => return config_error(&e),
647    };
648
649    let wrapped_targets: BTreeSet<String> = discovery.iter().map(|s| s.target.clone()).collect();
650    let topology = crate::doctor::classify_topology(scan, &wrapped_targets);
651    let excluded_hosts: BTreeSet<&str> =
652        topology.excluded.iter().map(|e| e.host.as_str()).collect();
653
654    let generated_set: BTreeSet<&str> = generated.iter().map(String::as_str).collect();
655    let added: Vec<String> = generated_set
656        .iter()
657        .filter(|t| !existing.contains(**t) && !excluded_hosts.contains(**t))
658        .map(|t| (*t).to_owned())
659        .collect();
660    let removed: Vec<String> = existing
661        .iter()
662        .filter(|t| !generated_set.contains(t.as_str()))
663        .cloned()
664        .collect();
665    let unchanged: Vec<String> = generated_set
666        .iter()
667        .filter(|t| existing.contains(**t))
668        .map(|t| (*t).to_owned())
669        .collect();
670    let notes = diff_notes(scan, &added);
671
672    let ops = if existing_text.is_none() {
673        // No file yet: the patch creates the generated keel.toml (header
674        // comments included), restricted to `added` — which already excludes
675        // dependency-averse-only hosts.
676        vec![PolicyOp::AppendBlock {
677            text: render_keel_toml_for_targets(scan, discovery, &added, stamp),
678        }]
679    } else {
680        let by_target: BTreeMap<&str, &TargetStats> =
681            discovery.iter().map(|s| (s.target.as_str(), s)).collect();
682        let mut ops: Vec<PolicyOp> = removed
683            .iter()
684            .map(|t| PolicyOp::Remove {
685                path: PolicyPath::new(["target", t.as_str()]),
686            })
687            .collect();
688        ops.extend(added.iter().map(|t| PolicyOp::AppendBlock {
689            text: render_target_block(t, scan.targets.get(t), by_target.get(t.as_str()).copied()),
690        }));
691        ops
692    };
693    let proposal = match propose(existing_text.as_deref(), &ops) {
694        Ok(p) => p,
695        Err(e) => return config_error(&e.to_string()),
696    };
697
698    let mut human = String::from("keel \u{25b8} keel init --diff\n");
699    if added.is_empty() && removed.is_empty() {
700        if topology.excluded.is_empty() {
701            human.push_str("  no changes: every discovered target is already in keel.toml.\n");
702        } else {
703            human.push_str(
704                "  no policy changes: every discovered target is already in keel.toml or excluded below.\n",
705            );
706        }
707    } else {
708        for t in &added {
709            let line = format!("  + [target.\"{t}\"]   (found in code, not in keel.toml)\n");
710            human.push_str(&line);
711        }
712        for t in &removed {
713            let line = format!("  - [target.\"{t}\"]   (in keel.toml, no longer found in code)\n");
714            human.push_str(&line);
715        }
716    }
717    if !proposal.patch.is_empty() {
718        human.push_str("\napply with `git apply` (or `patch -p1`):\n\n");
719        human.push_str(&proposal.patch);
720    }
721    human.push_str(&render_diff_trailer(&topology.excluded, &notes));
722    let report = DiffReport {
723        added,
724        changes: proposal.changes,
725        notes,
726        patch: proposal.patch,
727        removed,
728        unchanged,
729    };
730    Rendered::ok(human, to_json(&report))
731}
732
733/// The current `keel.toml` text; `None` when the file does not exist (which
734/// selects the `/dev/null` creation patch).
735fn read_existing(toml_path: &Path) -> Result<Option<String>, String> {
736    if !toml_path.exists() {
737        return Ok(None);
738    }
739    std::fs::read_to_string(toml_path)
740        .map(Some)
741        .map_err(|e| format!("could not read {}: {e}", toml_path.display()))
742}
743
744/// The set of `[target."…"]` keys declared in an existing `keel.toml`.
745fn existing_targets(text: &str, toml_path: &Path) -> Result<BTreeSet<String>, String> {
746    let value: toml::Value = text
747        .parse()
748        .map_err(|e| format!("{} is not valid TOML: {e}", toml_path.display()))?;
749    let mut set = BTreeSet::new();
750    if let Some(table) = value.get("target").and_then(toml::Value::as_table) {
751        for key in table.keys() {
752            set.insert(key.clone());
753        }
754    }
755    Ok(set)
756}
757
758/// Append `.keel/` to `.gitignore` (creating it if absent) when not already
759/// ignored. Returns whether the file was changed.
760fn update_gitignore(project: &Path) -> std::io::Result<bool> {
761    let path = project.join(".gitignore");
762    if !path.exists() {
763        std::fs::write(&path, ".keel/\n")?;
764        return Ok(true);
765    }
766    let text = std::fs::read_to_string(&path)?;
767    let ignored = text
768        .lines()
769        .map(str::trim)
770        .any(|l| l == ".keel" || l == ".keel/");
771    if ignored {
772        return Ok(false);
773    }
774    let mut updated = text;
775    if !updated.ends_with('\n') && !updated.is_empty() {
776        updated.push('\n');
777    }
778    updated.push_str(".keel/\n");
779    std::fs::write(&path, updated)?;
780    Ok(true)
781}
782
783/// Whether `project` contains any `.py` file — used to distinguish "python3
784/// was not found" from "there was nothing to scan" in both `init` and `flows
785/// suggest`'s warnings.
786pub(crate) fn has_python_files(project: &Path) -> bool {
787    let mut found = Vec::new();
788    scan::collect_files(project, &["py"], &mut found);
789    !found.is_empty()
790}
791
792/// A config/usage failure (exit 2), rendered for both audiences.
793fn config_error(message: &str) -> Rendered {
794    #[derive(Serialize)]
795    struct ErrReport<'a> {
796        code: &'static str,
797        error: &'a str,
798    }
799    let human = format!("keel \u{25b8} KEEL-E001: {message}");
800    Rendered {
801        human,
802        json: to_json(&ErrReport {
803            code: "KEEL-E001",
804            error: message,
805        }),
806        exit: EXIT_USAGE,
807        to_stderr: true,
808    }
809    .with_exit(EXIT_USAGE)
810}
811
812/// `"s"` unless `n == 1` — shared by every report that pluralizes a count noun.
813pub(crate) fn plural(n: usize) -> &'static str {
814    if n == 1 { "" } else { "s" }
815}
816
817/// Today's date as `YYYY-MM-DD` (UTC). Only reached under `--stamp`, so the
818/// determinism guarantee (no wall clock by default) holds. Civil-date math is
819/// Hinnant's algorithm — no dependency, no locale.
820fn today_utc() -> String {
821    let secs = std::time::SystemTime::now()
822        .duration_since(std::time::UNIX_EPOCH)
823        .map_or(0, |d| d.as_secs());
824    let days = i64::try_from(secs / 86_400).unwrap_or(0);
825    let (y, m, d) = civil_from_days(days);
826    format!("{y:04}-{m:02}-{d:02}")
827}
828
829/// Convert days-since-epoch to `(year, month, day)` (proleptic Gregorian).
830fn civil_from_days(z: i64) -> (i64, u32, u32) {
831    let z = z + 719_468;
832    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
833    let doe = z - era * 146_097;
834    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
835    let y = yoe + era * 400;
836    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
837    let mp = (5 * doy + 2) / 153;
838    let d = doy - (153 * mp + 2) / 5 + 1;
839    let m = if mp < 10 { mp + 3 } else { mp - 9 };
840    let y = if m <= 2 { y + 1 } else { y };
841    #[expect(
842        clippy::cast_sign_loss,
843        clippy::cast_possible_truncation,
844        reason = "m,d in 1..=31"
845    )]
846    (y, m as u32, d as u32)
847}
848
849#[cfg(test)]
850mod tests {
851    use std::fs;
852    use std::process::{Command, Stdio};
853
854    use keel_journal::ErrorClass;
855    use tempfile::TempDir;
856
857    use super::*;
858
859    /// Whether `python3` is on PATH — gates the Python-scan end-to-end tests,
860    /// mirroring `scan::python`'s test helper (private to that module, so
861    /// duplicated here rather than shared across crates).
862    fn python3_present() -> bool {
863        Command::new("python3")
864            .arg("--version")
865            .stdout(Stdio::null())
866            .stderr(Stdio::null())
867            .status()
868            .is_ok_and(|s| s.success())
869    }
870
871    /// A `TargetStats` for `llm:openai` with the given call count and observation
872    /// window; every other counter is inert (irrelevant to rate derivation).
873    fn llm_stats(calls: i64, first_seen_ms: i64, last_seen_ms: i64) -> TargetStats {
874        TargetStats {
875            target: "llm:openai".to_owned(),
876            calls,
877            attempts: calls,
878            retries: 0,
879            successes: calls,
880            failures: 0,
881            cache_hits: 0,
882            throttled: 0,
883            breaker_opens: 0,
884            total_latency_ms: 0,
885            max_latency_ms: 0,
886            first_seen_ms,
887            last_seen_ms,
888            last_error_class: None,
889            last_error_status: None,
890            not_retried: 0,
891            unwrapped_calls: 0,
892        }
893    }
894
895    fn host_scan() -> ScanResult {
896        let mut s = ScanResult {
897            files_scanned: 2,
898            python_available: true,
899            ..ScanResult::default()
900        };
901        // Reuse the private add via a fresh evidence set.
902        s.targets.insert(
903            "api.example.com".to_owned(),
904            TargetEvidence {
905                class: TargetClass::Host,
906                sightings: [scan::Sighting {
907                    file: "app.py".into(),
908                    line: 4,
909                }]
910                .into_iter()
911                .collect(),
912            },
913        );
914        s.targets.insert(
915            "llm:openai".to_owned(),
916            TargetEvidence {
917                class: TargetClass::Llm,
918                sightings: [scan::Sighting {
919                    file: "app.py".into(),
920                    line: 2,
921                }]
922                .into_iter()
923                .collect(),
924            },
925        );
926        s
927    }
928
929    #[test]
930    fn header_counts_and_no_date_by_default() {
931        let out = render_keel_toml(&host_scan(), &[], None);
932        assert!(
933            out.starts_with("# Generated by keel init from 2 static scans + 0 observed runs\n")
934        );
935        assert!(!out.contains("202"), "no date without --stamp");
936    }
937
938    #[test]
939    fn stamp_adds_a_date() {
940        let out = render_keel_toml(&host_scan(), &[], Some("2026-07-12"));
941        assert!(out.lines().next().unwrap().ends_with(" (2026-07-12)"));
942    }
943
944    #[test]
945    fn host_and_llm_blocks_cite_evidence() {
946        let out = render_keel_toml(&host_scan(), &[], None);
947        assert!(out.contains("[target.\"api.example.com\"]"));
948        assert!(out.contains("# seen in: app.py:4"));
949        assert!(out.contains("[target.\"llm:openai\"]"));
950        assert!(out.contains("# seen in: app.py:2"));
951        assert!(out.contains("cache   = { mode = \"dev\" }"));
952    }
953
954    #[test]
955    fn discovery_only_target_is_surfaced_with_observed_comment() {
956        let scan = ScanResult {
957            files_scanned: 1,
958            python_available: true,
959            ..ScanResult::default()
960        };
961        let stats = TargetStats {
962            target: "api.dynamic.com".to_owned(),
963            calls: 120,
964            attempts: 132,
965            retries: 12,
966            successes: 120,
967            failures: 0,
968            cache_hits: 0,
969            throttled: 0,
970            breaker_opens: 0,
971            total_latency_ms: 12_000,
972            max_latency_ms: 300,
973            first_seen_ms: 0,
974            last_seen_ms: 120_000, // 2 minutes → 60/min
975            last_error_class: None,
976            last_error_status: None,
977            not_retried: 0,
978            unwrapped_calls: 0,
979        };
980        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
981        assert!(out.contains("[target.\"api.dynamic.com\"]"));
982        assert!(out.contains("# seen only at runtime (.keel/discovery.db)"));
983        assert!(out.contains("# observed: 120 calls, 12 retries, ~60.0/min mean"));
984        // header now reports one observed run
985        assert!(out.contains("+ 1 observed run\n"));
986    }
987
988    #[test]
989    fn error_class_import_is_available() {
990        // Guards the keel_journal re-export used by status/doctor tests too.
991        let _ = ErrorClass::Http;
992    }
993
994    #[test]
995    fn default_body_matches_the_frozen_pack() {
996        // The hardcoded policy bodies must equal contracts/defaults.toml.
997        let defaults: toml::Value = include_str!("../contract/defaults.toml")
998            .parse()
999            .expect("defaults.toml parses");
1000        let outbound = &defaults["defaults"]["outbound"];
1001        assert_eq!(outbound["timeout"].as_str(), Some("30s"));
1002        assert_eq!(outbound["retry"]["attempts"].as_integer(), Some(3));
1003        assert_eq!(outbound["breaker"]["cooldown"].as_str(), Some("15s"));
1004        let llm = &defaults["defaults"]["llm"];
1005        assert_eq!(llm["timeout"].as_str(), Some("120s"));
1006        assert_eq!(llm["retry"]["attempts"].as_integer(), Some(6));
1007        assert_eq!(llm["breaker"]["cooldown"].as_str(), Some("30s"));
1008        assert_eq!(llm["cache"]["mode"].as_str(), Some("dev"));
1009        // and the bodies we emit reflect those values
1010        assert!(policy_body(TargetClass::Host).contains("attempts = 3"));
1011        assert!(policy_body(TargetClass::Host).contains("cooldown = \"15s\""));
1012        assert!(policy_body(TargetClass::Llm).contains("attempts = 6"));
1013        assert!(policy_body(TargetClass::Llm).contains("cooldown = \"30s\""));
1014        assert!(policy_body(TargetClass::Llm).contains("mode = \"dev\""));
1015    }
1016
1017    #[test]
1018    fn civil_date_epoch_is_1970_01_01() {
1019        assert_eq!(civil_from_days(0), (1970, 1, 1));
1020        assert_eq!(civil_from_days(19_997), (2024, 10, 1));
1021    }
1022
1023    // ---- item 3: evidence-tuned llm rate derivation ----
1024
1025    #[test]
1026    fn round_up_clean_walks_the_1_2_5_series() {
1027        assert_eq!(round_up_clean(0), 0);
1028        assert_eq!(round_up_clean(1), 1);
1029        assert_eq!(round_up_clean(3), 5);
1030        assert_eq!(round_up_clean(6), 10);
1031        assert_eq!(round_up_clean(11), 20);
1032        assert_eq!(round_up_clean(50), 50);
1033        assert_eq!(round_up_clean(60), 100);
1034        assert_eq!(round_up_clean(123), 200);
1035        assert_eq!(round_up_clean(300), 500);
1036        assert_eq!(round_up_clean(501), 1_000);
1037    }
1038
1039    #[test]
1040    fn llm_rate_is_mean_times_three_rounded_up_to_a_clean_value() {
1041        // 200 calls over a 2-min window → mean 100/min → ×3 = 300 → clean 500.
1042        let s = llm_stats(200, 0, 120_000);
1043        assert_eq!(mean_per_min_floor(&s), 100);
1044        assert_eq!(llm_rate_per_min(&s), 500);
1045    }
1046
1047    #[test]
1048    fn llm_rate_floors_at_60_for_sparse_traffic() {
1049        // 5 calls over 1 min → mean 5/min → ×3 = 15 → clean 20 → floored to 60.
1050        let s = llm_stats(5, 0, 60_000);
1051        assert_eq!(mean_per_min_floor(&s), 5);
1052        assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
1053    }
1054
1055    #[test]
1056    fn llm_rate_zero_span_window_falls_back_to_floor() {
1057        // Single-instant window (first_seen == last_seen): no mean derivable.
1058        let s = llm_stats(500, 1_000, 1_000);
1059        assert_eq!(mean_per_min_floor(&s), 0);
1060        assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
1061    }
1062
1063    #[test]
1064    fn observed_llm_target_gets_an_active_rate_line() {
1065        let scan = ScanResult {
1066            files_scanned: 1,
1067            python_available: true,
1068            ..ScanResult::default()
1069        };
1070        let stats = llm_stats(200, 0, 120_000);
1071        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1072
1073        assert!(out.contains("[target.\"llm:openai\"]"));
1074        assert!(out.contains("rate    = \"500/min\""));
1075        assert!(out.contains("# headroom over your observed mean of ~100/min"));
1076        // We measure a mean, never a peak — the word must never appear.
1077        assert!(!out.contains("peak"));
1078        // The rate line sits between breaker and cache.
1079        let rate_at = out.find("rate    =").expect("rate line present");
1080        let cache_at = out.find("cache   =").expect("cache line present");
1081        assert!(rate_at < cache_at, "rate must precede cache");
1082    }
1083
1084    #[test]
1085    fn zero_span_llm_target_emits_floor_with_honest_comment() {
1086        let scan = ScanResult {
1087            files_scanned: 1,
1088            python_available: true,
1089            ..ScanResult::default()
1090        };
1091        let stats = llm_stats(9, 5_000, 5_000);
1092        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1093        assert!(out.contains("rate    = \"60/min\""));
1094        assert!(out.contains("# floor: single observation window, no mean to derive"));
1095        assert!(!out.contains("peak"));
1096    }
1097
1098    #[test]
1099    fn observed_host_target_stays_comments_only() {
1100        // Host targets never get an active rate, even with observed traffic.
1101        let scan = ScanResult {
1102            files_scanned: 1,
1103            python_available: true,
1104            ..ScanResult::default()
1105        };
1106        let stats = TargetStats {
1107            target: "api.host.example".to_owned(),
1108            ..llm_stats(200, 0, 120_000)
1109        };
1110        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1111        assert!(out.contains("[target.\"api.host.example\"]"));
1112        assert!(
1113            !out.contains("rate    ="),
1114            "host targets must not emit an active rate line"
1115        );
1116    }
1117
1118    // ---- item 2: keel init write path ----
1119
1120    #[test]
1121    fn refuses_when_keel_toml_already_exists() {
1122        let dir = TempDir::new().unwrap();
1123        fs::write(dir.path().join("keel.toml"), "# hand-written\n").unwrap();
1124
1125        let r = run(dir.path(), InitOptions::default());
1126
1127        assert_eq!(r.exit, EXIT_USAGE);
1128        assert!(r.to_stderr);
1129        assert!(r.human.contains("already exists"));
1130        // The existing file is left untouched.
1131        assert_eq!(
1132            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1133            "# hand-written\n"
1134        );
1135    }
1136
1137    // ---- agents-cli layout redirection ----
1138
1139    /// An `agents-cli` project (manifest + agent dir at the root) gets its
1140    /// generated `keel.toml` written into the agent directory, not the
1141    /// project root — the generated Dockerfile only COPYs the agent dir.
1142    #[test]
1143    fn agents_cli_project_writes_keel_toml_into_the_agent_dir() {
1144        let dir = TempDir::new().unwrap();
1145        fs::create_dir(dir.path().join("app")).unwrap();
1146        fs::write(
1147            dir.path().join("agents-cli-manifest.yaml"),
1148            "schema_version: 1\nagent_directory: app\n",
1149        )
1150        .unwrap();
1151
1152        let r = run(dir.path(), InitOptions::default());
1153
1154        assert_eq!(r.exit, crate::EXIT_OK);
1155        assert!(!dir.path().join("keel.toml").exists(), "no root keel.toml");
1156        assert!(
1157            dir.path().join("app").join("keel.toml").exists(),
1158            "keel.toml lands in the agent directory"
1159        );
1160        assert_eq!(
1161            r.json["wrote"].as_str().unwrap(),
1162            dir.path()
1163                .join("app")
1164                .join("keel.toml")
1165                .display()
1166                .to_string()
1167        );
1168    }
1169
1170    /// A project with no `agents-cli-manifest.yaml` is unaffected: `keel.toml`
1171    /// still lands at the project root, byte-identical to the non-agents-cli
1172    /// goldens.
1173    #[test]
1174    fn non_agents_cli_project_writes_keel_toml_at_the_root() {
1175        let dir = TempDir::new().unwrap();
1176
1177        let r = run(dir.path(), InitOptions::default());
1178
1179        assert_eq!(r.exit, crate::EXIT_OK);
1180        assert!(dir.path().join("keel.toml").exists());
1181    }
1182
1183    /// The refuse-if-exists guard applies to the redirected path: an existing
1184    /// `keel.toml` inside the agent directory blocks the write even though the
1185    /// project root has none.
1186    #[test]
1187    fn agents_cli_refuses_when_the_redirected_path_already_exists() {
1188        let dir = TempDir::new().unwrap();
1189        fs::create_dir(dir.path().join("app")).unwrap();
1190        fs::write(
1191            dir.path().join("agents-cli-manifest.yaml"),
1192            "agent_directory: app\n",
1193        )
1194        .unwrap();
1195        fs::write(dir.path().join("app").join("keel.toml"), "# hand-written\n").unwrap();
1196
1197        let r = run(dir.path(), InitOptions::default());
1198
1199        assert_eq!(r.exit, EXIT_USAGE);
1200        assert!(r.human.contains("already exists"));
1201        assert_eq!(
1202            fs::read_to_string(dir.path().join("app").join("keel.toml")).unwrap(),
1203            "# hand-written\n"
1204        );
1205    }
1206
1207    /// `agents_cli_toml_path` itself: `None` for a non-agents-cli project.
1208    #[test]
1209    fn agents_cli_toml_path_is_none_without_a_manifest() {
1210        let dir = TempDir::new().unwrap();
1211        assert!(agents_cli_toml_path(dir.path()).is_none());
1212    }
1213
1214    /// CRITICAL regression: `agent_directory: ../elsewhere` must never escape
1215    /// `project`, even though the sibling directory it names genuinely exists
1216    /// on disk (the reviewer's exact repro). Both layers of the fix apply
1217    /// here — `agents_cli::find_agents_cli_layout` already rejects the `..`
1218    /// component, so `agents_cli_toml_path` sees no layout at all and returns
1219    /// `None` via `?`; this test pins that end-to-end outcome from `init`'s
1220    /// side rather than re-testing `agents_cli`'s parser directly.
1221    #[test]
1222    fn agents_cli_toml_path_is_none_when_agent_directory_escapes_the_project() {
1223        let root = TempDir::new().unwrap();
1224        let project = root.path().join("project");
1225        fs::create_dir(&project).unwrap();
1226        fs::create_dir(root.path().join("elsewhere")).unwrap();
1227        fs::write(
1228            project.join("agents-cli-manifest.yaml"),
1229            "agent_directory: ../elsewhere\n",
1230        )
1231        .unwrap();
1232
1233        assert!(agents_cli_toml_path(&project).is_none());
1234
1235        // And the same repro through the full `run` path never writes
1236        // outside `project`.
1237        let r = run(&project, InitOptions::default());
1238        assert_eq!(r.exit, crate::EXIT_OK);
1239        assert!(project.join("keel.toml").exists());
1240        assert!(!root.path().join("elsewhere").join("keel.toml").exists());
1241    }
1242
1243    #[test]
1244    fn diff_reports_added_and_removed_targets_precisely() {
1245        let dir = TempDir::new().unwrap();
1246        // JS scan (pure Rust, no python3) will find `api.example.com`.
1247        fs::write(
1248            dir.path().join("app.mjs"),
1249            "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1250        )
1251        .unwrap();
1252        // An existing keel.toml declares a target the scan will NOT find.
1253        fs::write(
1254            dir.path().join("keel.toml"),
1255            "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n",
1256        )
1257        .unwrap();
1258
1259        let r = run(
1260            dir.path(),
1261            InitOptions {
1262                diff: true,
1263                stamp: false,
1264                agents: false,
1265            },
1266        );
1267
1268        assert_eq!(r.exit, crate::EXIT_OK);
1269        assert_eq!(
1270            r.json["added"].as_array().unwrap(),
1271            &vec![serde_json::json!("api.example.com")]
1272        );
1273        assert_eq!(
1274            r.json["removed"].as_array().unwrap(),
1275            &vec![serde_json::json!("api.gone.example")]
1276        );
1277        assert!(r.json["unchanged"].as_array().unwrap().is_empty());
1278        assert!(r.human.contains("+ [target.\"api.example.com\"]"));
1279        assert!(r.human.contains("- [target.\"api.gone.example\"]"));
1280        // --diff never writes.
1281        assert_eq!(
1282            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1283            "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n"
1284        );
1285    }
1286
1287    /// dx-spec §5 (diffs as the lingua franca): `--diff` emits an applyable
1288    /// patch. Applying it removes stale blocks and appends evidence-cited new
1289    /// ones while user tuning outside the touched blocks survives byte-for-byte.
1290    #[test]
1291    fn diff_emits_an_applyable_patch_and_structured_changes() {
1292        let dir = TempDir::new().unwrap();
1293        fs::write(
1294            dir.path().join("app.mjs"),
1295            "// 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",
1296        )
1297        .unwrap();
1298        let old = "\
1299# hand-tuned: keep this comment
1300
1301[target.\"api.example.com\"]
1302timeout = \"9s\"   # user tuning survives
1303
1304[target.\"api.gone.example\"]  # stale
1305timeout = \"5s\"
1306";
1307        fs::write(dir.path().join("keel.toml"), old).unwrap();
1308
1309        let r = run(
1310            dir.path(),
1311            InitOptions {
1312                diff: true,
1313                stamp: false,
1314                agents: false,
1315            },
1316        );
1317
1318        assert_eq!(r.exit, crate::EXIT_OK);
1319        let patch = r.json["patch"].as_str().unwrap();
1320        assert!(
1321            patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"),
1322            "{patch}"
1323        );
1324        assert!(r.human.contains("apply with `git apply`"));
1325        assert!(
1326            r.human.contains(patch),
1327            "the human output carries the patch verbatim"
1328        );
1329
1330        let applied = crate::diff::apply_unified(old, patch).unwrap();
1331        let value: toml::Value = applied.parse().expect("applied file parses");
1332        assert!(value["target"].get("api.gone.example").is_none());
1333        assert!(value["target"].get("api.new.example").is_some());
1334        assert!(applied.contains("# hand-tuned: keep this comment"));
1335        assert!(applied.contains("timeout = \"9s\"   # user tuning survives"));
1336        // The added block is byte-identical to what a fresh init would write.
1337        assert!(applied.contains("[target.\"api.new.example\"]"));
1338        assert!(applied.contains("# seen in: app.mjs:3"));
1339
1340        // Structured hunks: one removal, one addition, sorted by path.
1341        let changes = r.json["changes"].as_array().unwrap();
1342        let paths: Vec<&str> = changes
1343            .iter()
1344            .map(|c| c["path"].as_str().unwrap())
1345            .collect();
1346        assert_eq!(
1347            paths,
1348            ["target.\"api.gone.example\"", "target.\"api.new.example\""]
1349        );
1350        assert!(changes[0]["after"].is_null());
1351        assert!(changes[1]["before"].is_null());
1352        // --diff never writes.
1353        assert_eq!(
1354            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1355            old
1356        );
1357    }
1358
1359    /// dx-spec's honesty triad, `--diff` leg: a host seen only inside a
1360    /// dependency-averse file (Task 7's `keel doctor` bucket, reused here via
1361    /// `classify_topology`) must never get a proposed policy block, and the
1362    /// diff must say why — so `keel doctor` and `keel init --diff` never
1363    /// disagree about which hosts get policy proposed.
1364    #[test]
1365    fn diff_skips_dependency_averse_only_hosts_and_says_why() {
1366        if !python3_present() {
1367            eprintln!("skip: python3 not available");
1368            return;
1369        }
1370        let dir = TempDir::new().unwrap();
1371        fs::write(
1372            dir.path().join("risk_gate.py"),
1373            "\"\"\"risk gate. stdlib only.\"\"\"\nimport urllib.request\nU = \"https://api.broker.com/v2\"\n",
1374        )
1375        .unwrap();
1376        fs::write(
1377            dir.path().join("app.py"),
1378            "import httpx\nU = \"https://api.normal.com/v1\"\n",
1379        )
1380        .unwrap();
1381
1382        let r = run(
1383            dir.path(),
1384            InitOptions {
1385                diff: true,
1386                stamp: false,
1387                agents: false,
1388            },
1389        );
1390
1391        assert_eq!(r.exit, crate::EXIT_OK);
1392        let text = &r.human;
1393        assert!(
1394            text.contains("api.normal.com"),
1395            "normal host proposed: {text}"
1396        );
1397        assert!(
1398            !text.contains("[target.\"api.broker.com\"]"),
1399            "no policy for the gate-file host: {text}"
1400        );
1401        assert!(
1402            text.contains("excluded (dependency-averse): api.broker.com"),
1403            "{text}"
1404        );
1405        // The structured `added` list must agree with the human text.
1406        let added = r.json["added"].as_array().unwrap();
1407        assert!(added.iter().any(|v| v == "api.normal.com"));
1408        assert!(!added.iter().any(|v| v == "api.broker.com"));
1409    }
1410
1411    /// WS3: `keel init --diff` annotates proposals with what becomes deletable —
1412    /// hand-rolled patterns attributed to a proposed target, and the
1413    /// pre-existing-resilience signal (today doctor-only) — in both the JSON
1414    /// (`notes`) and the human text (`# note:` lines).
1415    #[test]
1416    fn init_diff_annotates_simplifications_and_preexisting_resilience() {
1417        if !python3_present() {
1418            eprintln!("skip: python3 not available");
1419            return;
1420        }
1421        let dir = TempDir::new().unwrap();
1422        fs::write(
1423            dir.path().join("app.py"),
1424            r#"import time
1425import httpx
1426import tenacity
1427
1428API = "https://api.normal.com/v1"
1429
1430def caller():
1431    n = 0
1432    while True:
1433        try:
1434            return httpx.get(API)
1435        except Exception:
1436            n += 1
1437            time.sleep(1)
1438"#,
1439        )
1440        .unwrap();
1441        let r = run(
1442            dir.path(),
1443            InitOptions {
1444                diff: true,
1445                stamp: false,
1446                agents: false,
1447            },
1448        );
1449        let json = serde_json::to_string(&r.json).unwrap();
1450        assert!(
1451            json.contains("\"notes\""),
1452            "DiffReport carries notes: {json}"
1453        );
1454        assert!(json.contains("hand-rolled-retry"));
1455        assert!(
1456            json.contains("app.py:9"),
1457            "anchored at the while loop: {json}"
1458        );
1459        assert!(json.contains("tenacity"));
1460        assert!(r.human.contains("# note:"));
1461        assert!(r.human.contains("hand-rolled-retry"));
1462        assert!(r.human.contains("tenacity"));
1463    }
1464
1465    /// With no keel.toml the patch creates the whole generated file from
1466    /// `/dev/null`, byte-identical to what `keel init` would write.
1467    #[test]
1468    fn diff_without_existing_file_is_a_dev_null_creation_patch() {
1469        let dir = TempDir::new().unwrap();
1470        fs::write(
1471            dir.path().join("app.mjs"),
1472            "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1473        )
1474        .unwrap();
1475
1476        let r = run(
1477            dir.path(),
1478            InitOptions {
1479                diff: true,
1480                stamp: false,
1481                agents: false,
1482            },
1483        );
1484
1485        assert_eq!(r.exit, crate::EXIT_OK);
1486        let patch = r.json["patch"].as_str().unwrap();
1487        assert!(
1488            patch.starts_with("--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,"),
1489            "{patch}"
1490        );
1491        let scanned = scan::scan(dir.path());
1492        let expected = render_keel_toml(&scanned, &[], None);
1493        assert_eq!(crate::diff::apply_unified("", patch).unwrap(), expected);
1494        assert_eq!(
1495            r.json["added"].as_array().unwrap(),
1496            &vec![serde_json::json!("api.example.com")]
1497        );
1498    }
1499
1500    // ---- keel init --agents ----
1501
1502    #[test]
1503    fn agents_creates_then_is_idempotent() {
1504        let dir = TempDir::new().unwrap();
1505        let opts = InitOptions {
1506            agents: true,
1507            ..InitOptions::default()
1508        };
1509        let r1 = run(dir.path(), opts);
1510        assert_eq!(r1.exit, crate::EXIT_OK);
1511        assert!(r1.json["wrote"].as_bool().unwrap());
1512        let path = dir.path().join("AGENTS.md");
1513        let c1 = fs::read_to_string(&path).unwrap();
1514        assert!(c1.contains("## Keel (resilience & durable execution)"));
1515        assert!(c1.contains("keel doctor --json"));
1516
1517        // Re-run: nothing to change → already current, file byte-identical.
1518        let r2 = run(dir.path(), opts);
1519        assert!(r2.json["already_current"].as_bool().unwrap());
1520        assert_eq!(fs::read_to_string(&path).unwrap(), c1);
1521    }
1522
1523    #[test]
1524    fn splice_appends_then_replaces_region_without_duplicating() {
1525        let block = agents_block();
1526        // Append below existing prose.
1527        let (out, replaced, wrote) = splice_agents_block("# My project\n", &block);
1528        assert!(!replaced && wrote);
1529        assert!(out.starts_with("# My project\n\n"));
1530        assert!(out.contains(AGENTS_BEGIN) && out.contains(AGENTS_END));
1531        // Re-splicing the same block replaces in place and is a no-op write.
1532        let (out2, replaced2, wrote2) = splice_agents_block(&out, &block);
1533        assert!(replaced2 && !wrote2);
1534        assert_eq!(out2, out);
1535        assert_eq!(
1536            out2.matches(AGENTS_BEGIN).count(),
1537            1,
1538            "exactly one Keel block"
1539        );
1540    }
1541
1542    #[test]
1543    fn gitignore_is_created_when_absent() {
1544        let dir = TempDir::new().unwrap();
1545        assert!(update_gitignore(dir.path()).unwrap());
1546        assert_eq!(
1547            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1548            ".keel/\n"
1549        );
1550    }
1551
1552    #[test]
1553    fn gitignore_is_appended_when_keel_line_missing() {
1554        let dir = TempDir::new().unwrap();
1555        // No trailing newline: the appender must add one before `.keel/`.
1556        fs::write(dir.path().join(".gitignore"), "node_modules/\n*.log").unwrap();
1557
1558        assert!(update_gitignore(dir.path()).unwrap());
1559
1560        assert_eq!(
1561            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1562            "node_modules/\n*.log\n.keel/\n"
1563        );
1564    }
1565
1566    #[test]
1567    fn gitignore_is_a_noop_when_already_ignored() {
1568        let dir = TempDir::new().unwrap();
1569        let original = "build/\n.keel/\ncoverage/\n";
1570        fs::write(dir.path().join(".gitignore"), original).unwrap();
1571
1572        assert!(!update_gitignore(dir.path()).unwrap());
1573
1574        assert_eq!(
1575            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1576            original
1577        );
1578    }
1579
1580    /// CRITICAL containment test: `agents_cli_toml_path` rejects an absolute
1581    /// agent directory using canonicalization (layer 2), not Path::starts_with
1582    /// (which is purely syntactic). This is defense in depth: even though a
1583    /// manifest with `agent_directory: /etc` contains no `..` component (so
1584    /// layer 1 in agents_cli.rs does not reject it), `canonicalize` resolves it
1585    /// to the actual `/etc` on disk, which fails the starts_with check and
1586    /// returns None. If layer 2 were removed and only layer 1 remained, this
1587    /// would escape the project.
1588    #[test]
1589    fn agents_cli_toml_path_rejects_absolute_path_agent_directory() {
1590        let root = TempDir::new().unwrap();
1591        let project = root.path().join("project");
1592        fs::create_dir(&project).unwrap();
1593        let outside = root.path().join("outside");
1594        fs::create_dir(&outside).unwrap();
1595
1596        fs::write(
1597            project.join("agents-cli-manifest.yaml"),
1598            // Using the actual outside TempDir path (absolute, no ..) — layer 1
1599            // does NOT reject this because there's no ParentDir component.
1600            format!("agent_directory: {}\n", outside.display()),
1601        )
1602        .unwrap();
1603
1604        // agents_cli_toml_path should return None because canonicalization
1605        // resolves the absolute path and discovers it's outside project.
1606        assert!(agents_cli_toml_path(&project).is_none());
1607
1608        // End-to-end through run: writes to project root, never to outside.
1609        let r = run(&project, InitOptions::default());
1610        assert_eq!(r.exit, crate::EXIT_OK);
1611        assert!(project.join("keel.toml").exists());
1612        assert!(!outside.join("keel.toml").exists());
1613    }
1614
1615    /// CRITICAL containment test: `agents_cli_toml_path` rejects a symlink
1616    /// agent directory that points outside the project. This is defense in
1617    /// depth: the manifest's `agent_directory` is a valid relative path inside
1618    /// `project`, but if it's a symlink pointing outside, `canonicalize` will
1619    /// resolve it to the actual target and fail the starts_with check. Layer 1
1620    /// (agents_cli.rs rejecting `..`) does not catch this — only canonicalization
1621    /// (layer 2) does.
1622    #[cfg(unix)]
1623    #[test]
1624    fn agents_cli_toml_path_rejects_symlink_escape_to_outside_project() {
1625        use std::os::unix::fs as unix_fs;
1626
1627        let root = TempDir::new().unwrap();
1628        let project = root.path().join("project");
1629        fs::create_dir(&project).unwrap();
1630        let outside = root.path().join("outside");
1631        fs::create_dir(&outside).unwrap();
1632
1633        // Create a symlink inside project that points to outside.
1634        let symlink = project.join("app");
1635        unix_fs::symlink(&outside, &symlink).unwrap();
1636
1637        fs::write(
1638            project.join("agents-cli-manifest.yaml"),
1639            "agent_directory: app\n",
1640        )
1641        .unwrap();
1642
1643        // agents_cli_toml_path should return None because canonicalize resolves
1644        // the symlink to outside and fails the starts_with check.
1645        assert!(agents_cli_toml_path(&project).is_none());
1646
1647        // End-to-end through run: writes to project root, never to outside.
1648        let r = run(&project, InitOptions::default());
1649        assert_eq!(r.exit, crate::EXIT_OK);
1650        assert!(project.join("keel.toml").exists());
1651        assert!(!outside.join("keel.toml").exists());
1652    }
1653}