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    patch: String,
152    removed: Vec<String>,
153    unchanged: Vec<String>,
154}
155
156/// Run `keel init` for `project`.
157pub fn run(project: &Path, opts: InitOptions) -> Rendered {
158    if opts.agents {
159        return run_agents(project);
160    }
161    let scan = scan::scan(project);
162    let discovery = match evidence::read_discovery(project) {
163        Ok(d) => d,
164        Err(e) => return config_error(&e),
165    };
166
167    let content = render_keel_toml(&scan, &discovery, opts.stamp.then(today_utc).as_deref());
168    let targets = merged_targets(&scan, &discovery);
169
170    let agents_cli_path = agents_cli_toml_path(project);
171    let toml_path = agents_cli_path
172        .clone()
173        .unwrap_or_else(|| evidence::keel_toml(project));
174    if opts.diff {
175        return diff(&toml_path, &scan, &discovery, &targets, &content);
176    }
177    if toml_path.exists() {
178        return config_error(&format!(
179            "{} already exists. Run `keel init --diff` to preview changes, or edit it directly.",
180            toml_path.display()
181        ));
182    }
183
184    if let Err(e) = std::fs::write(&toml_path, &content) {
185        return config_error(&format!("could not write {}: {e}", toml_path.display()));
186    }
187    // Only note the agents-cli redirection once the write has actually
188    // happened — not on the `--diff` preview path above, and not on the
189    // refuse-if-exists error path just above that (neither one modifies
190    // anything, so neither should print a note about what would be written).
191    if agents_cli_path.is_some() {
192        eprintln!(
193            "keel \u{25b8} agents-cli project detected \u{2014} writing {} so it ships in the \
194             container image",
195            relative_display(project, &toml_path)
196        );
197    }
198    let gitignore_updated = update_gitignore(project).unwrap_or(false);
199
200    let mut warnings = String::new();
201    if !scan.python_available && has_python_files(project) {
202        warnings.push_str(
203            "\nkeel \u{25b8} note: python3 was not found; Python files were not scanned.\n",
204        );
205    }
206
207    let observed_runs = u32::from(!discovery.is_empty());
208    let human = format!(
209        "keel \u{25b8} wrote {} ({} target{}) from {} static scan{} + {} observed run{}.{}",
210        toml_path.display(),
211        targets.len(),
212        plural(targets.len()),
213        scan.files_scanned,
214        plural(scan.files_scanned),
215        observed_runs,
216        plural(observed_runs as usize),
217        if warnings.is_empty() {
218            String::new()
219        } else {
220            warnings
221        }
222    );
223    let report = WroteReport {
224        gitignore_updated,
225        observed_runs,
226        static_scans: scan.files_scanned,
227        targets,
228        wrote: toml_path.display().to_string(),
229    };
230    Rendered::ok(human, to_json(&report))
231}
232
233/// When `project` is inside a Google `agents-cli` layout (an
234/// `agents-cli-manifest.yaml` naming an `agent_directory`) and that agent
235/// directory is itself inside `project`, redirect the generated `keel.toml`
236/// there instead of the project root — the generated Dockerfile only `COPY`s
237/// `pyproject.toml`, `README.md`, `uv.lock*`, and the agent directory into the
238/// image, so a root `keel.toml` would never ship. Pure (no I/O side effects
239/// beyond the two `canonicalize` calls) — the caller decides whether and when
240/// to print a redirection note; see `run`. Returns `None` for a non-agents-cli
241/// project, or one whose agent directory resolves outside `project`.
242///
243/// The containment check canonicalizes both sides before comparing rather
244/// than using `Path::starts_with` directly: `starts_with` is purely
245/// component-syntactic, so `<project>/../elsewhere` lexically "starts with"
246/// `<project>` even though it resolves outside it. `agents_cli::
247/// find_agents_cli_layout` already rejects any `agent_directory` containing a
248/// `..` component (layer one), so this canonicalizing check (layer two) is
249/// defense in depth against anything that reaches `starts_with` unsanitized —
250/// e.g. an agent directory that is itself a symlink pointing outside
251/// `project`. Both paths are guaranteed to exist by this point (`project` is
252/// the directory `keel init` is running against; `find_agents_cli_layout`
253/// already checked `agent_dir` is a directory), so `canonicalize` failing
254/// here would itself indicate something adversarial (e.g. a TOCTOU removal)
255/// and correctly falls through to `None`.
256fn agents_cli_toml_path(project: &Path) -> Option<std::path::PathBuf> {
257    let layout = agents_cli::find_agents_cli_layout(project)?;
258    let canonical_project = std::fs::canonicalize(project).ok()?;
259    let canonical_agent_dir = std::fs::canonicalize(&layout.agent_dir).ok()?;
260    if !canonical_agent_dir.starts_with(&canonical_project) {
261        return None;
262    }
263    Some(layout.agent_dir.join("keel.toml"))
264}
265
266/// `target` relative to `base` when it is actually nested under `base`, else
267/// the absolute path unchanged. Mirrors `doctor::relative_display`: keeps the
268/// agents-cli redirection note reproducible across checkouts instead of
269/// embedding wherever this particular clone happens to sit on disk.
270fn relative_display(base: &Path, target: &Path) -> String {
271    target.strip_prefix(base).map_or_else(
272        |_| target.display().to_string(),
273        |rel| rel.display().to_string(),
274    )
275}
276
277/// The set of targets the generated file will contain: static findings unioned
278/// with discovery-only targets (runtime caught what the scan missed).
279fn merged_targets(scan: &ScanResult, discovery: &[TargetStats]) -> Vec<String> {
280    let mut set: BTreeSet<String> = scan.targets.keys().cloned().collect();
281    for stats in discovery {
282        set.insert(stats.target.clone());
283    }
284    set.into_iter().collect()
285}
286
287/// Render the full `keel.toml` text. Pure and deterministic — the snapshot
288/// tests pin its bytes.
289pub fn render_keel_toml(
290    scan: &ScanResult,
291    discovery: &[TargetStats],
292    stamp: Option<&str>,
293) -> String {
294    let by_target: BTreeMap<&str, &TargetStats> =
295        discovery.iter().map(|s| (s.target.as_str(), s)).collect();
296    let observed_runs = u32::from(!discovery.is_empty());
297
298    let mut out = String::new();
299    let date = stamp.map_or_else(String::new, |d| format!(" ({d})"));
300    let header = format!(
301        "# Generated by keel init from {} static scan{} + {} observed run{}{}\n",
302        scan.files_scanned,
303        plural(scan.files_scanned),
304        observed_runs,
305        plural(observed_runs as usize),
306        date,
307    );
308    out.push_str(&header);
309    out.push_str(
310        "# Every entry below was found in YOUR code. Delete anything; defaults still apply.\n",
311    );
312
313    for target in merged_targets(scan, discovery) {
314        out.push('\n');
315        let evidence = scan.targets.get(&target);
316        let stats = by_target.get(target.as_str()).copied();
317        out.push_str(&render_target_block(&target, evidence, stats));
318    }
319    out
320}
321
322/// Render one `[target."…"]` block (no leading blank line): header + evidence
323/// comment(s) + policy body. Shared by the full render and the `--diff` add
324/// hunks, so an added block in the patch is byte-identical to what a fresh
325/// `keel init` would write.
326fn render_target_block(
327    target: &str,
328    evidence: Option<&TargetEvidence>,
329    stats: Option<&TargetStats>,
330) -> String {
331    let mut buf = String::new();
332    let out = &mut buf;
333    let header = format!("[target.\"{target}\"]");
334    let seen_comment = evidence.map(|e| {
335        let labels = e
336            .sightings
337            .iter()
338            .map(scan::Sighting::label)
339            .collect::<Vec<_>>()
340            .join(", ");
341        format!("# seen in: {labels}")
342    });
343    let comment =
344        seen_comment.unwrap_or_else(|| "# seen only at runtime (.keel/discovery.db)".to_owned());
345    out.push_str(&pad_comment(&header, &comment));
346    out.push('\n');
347
348    if let Some(s) = stats {
349        let observed = format!("# {}\n", observed_comment(s));
350        out.push_str(&observed);
351    }
352
353    let class = evidence.map_or_else(
354        || {
355            if target.starts_with("llm:") {
356                TargetClass::Llm
357            } else {
358                TargetClass::Host
359            }
360        },
361        |e| e.class,
362    );
363    match (class, stats) {
364        // dx-spec §1 flagship: an observed `llm:*` target earns an *active* rate
365        // limit tuned from its own evidence, inserted between breaker and cache.
366        (TargetClass::Llm, Some(s)) => {
367            out.push_str(LLM_BODY_HEAD);
368            out.push_str(&observed_rate_line(s));
369            out.push('\n');
370            out.push_str(LLM_CACHE_LINE);
371        }
372        // Host targets stay comments-only even with observed traffic: imposing an
373        // active throttle on general outbound HTTP without an explicit opt-in
374        // would be a Level-0 surprise (dx-spec §1 hard rules). An evidence-tuned
375        // host rate is deliberately out of scope for v0.1.
376        _ => out.push_str(&policy_body(class)),
377    }
378    buf
379}
380
381/// Outbound-host policy body. Mirrors the frozen smart-defaults pack
382/// (`contracts/defaults.toml` outbound); a test asserts they stay in sync.
383const HOST_BODY: &str = concat!(
384    "timeout = \"30s\"\n",
385    "retry   = { attempts = 3, schedule = \"exp(200ms, x2, max 30s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
386    "breaker = { failures = 5, cooldown = \"15s\" }\n",
387);
388
389/// The LLM body up to and including the breaker line — everything that precedes
390/// the *optional* evidence-derived `rate` line. Mirrors `contracts/defaults.toml`
391/// llm pack.
392const LLM_BODY_HEAD: &str = concat!(
393    "timeout = \"120s\"\n",
394    "retry   = { attempts = 6, schedule = \"exp(500ms, x2, max 60s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
395    "breaker = { failures = 5, cooldown = \"30s\" }\n",
396);
397
398/// The LLM dev-cache line — always the last line of an `llm:*` block.
399const LLM_CACHE_LINE: &str =
400    "cache   = { mode = \"dev\" }          # dev-loop cache; disabled when KEEL_ENV=prod\n";
401
402/// Floor for an observed `llm:*` target's active rate, in calls/min. Below this
403/// the derived headroom is noise (LLM traffic is bursty), so we never emit an
404/// active limit under 60/min — also the value used when the observation window
405/// is a single instant (no mean to derive).
406const LLM_RATE_FLOOR_PER_MIN: u64 = 60;
407
408/// Headroom multiplier over the observed MEAN rate. The discovery store keeps
409/// only `calls` + `first_seen_ms`/`last_seen_ms` — it measures a mean, never a
410/// per-minute *peak* — so we scale the mean up generously to leave room for the
411/// peaks we did not measure. NEVER describe the result as a peak.
412const LLM_RATE_HEADROOM: u64 = 3;
413
414/// The policy body for a class *without* any evidence-derived keys. Values
415/// mirror the frozen smart-defaults pack (`contracts/defaults.toml`); a test
416/// asserts they stay in sync. Writing them out (rather than relying on the
417/// invisible defaults) makes the file self-documenting — the DX promise that
418/// "the generated file is the docs".
419fn policy_body(class: TargetClass) -> String {
420    match class {
421        TargetClass::Host => HOST_BODY.to_owned(),
422        TargetClass::Llm => format!("{LLM_BODY_HEAD}{LLM_CACHE_LINE}"),
423    }
424}
425
426/// The observed MEAN calls/minute as an integer floor, or `0` when the window is
427/// a single instant (`first_seen_ms == last_seen_ms`). Pure integer math keeps
428/// the output byte-deterministic. Basis for both the derived rate and its
429/// comment.
430fn mean_per_min_floor(s: &TargetStats) -> u64 {
431    let span_ms = u64::try_from((s.last_seen_ms - s.first_seen_ms).max(0)).unwrap_or(u64::MAX);
432    if span_ms == 0 {
433        return 0;
434    }
435    let calls = u64::try_from(s.calls.max(0)).unwrap_or(u64::MAX);
436    calls.saturating_mul(60_000) / span_ms
437}
438
439/// Derive an active per-minute rate limit for an observed `llm:*` target:
440/// `mean × LLM_RATE_HEADROOM`, [rounded up to a clean value](round_up_clean),
441/// clamped to a floor of [`LLM_RATE_FLOOR_PER_MIN`]. A single-instant window has
442/// no derivable mean, so it falls back to the floor. Deterministic integer math.
443fn llm_rate_per_min(s: &TargetStats) -> u64 {
444    let mean = mean_per_min_floor(s);
445    if mean == 0 {
446        return LLM_RATE_FLOOR_PER_MIN;
447    }
448    round_up_clean(mean.saturating_mul(LLM_RATE_HEADROOM)).max(LLM_RATE_FLOOR_PER_MIN)
449}
450
451/// Round `n` UP to the next "clean" value in the 1-2-5 decade series
452/// (…10, 20, 50, 100, 200, 500, 1000…) — the standard nice-number ceiling.
453/// `round_up_clean(0) == 0`.
454fn round_up_clean(n: u64) -> u64 {
455    if n == 0 {
456        return 0;
457    }
458    let mut unit = 1_u64;
459    loop {
460        for m in [1_u64, 2, 5] {
461            let candidate = m.saturating_mul(unit);
462            if candidate >= n {
463                return candidate;
464            }
465        }
466        match unit.checked_mul(10) {
467            Some(next) => unit = next,
468            None => return u64::MAX,
469        }
470    }
471}
472
473/// The active `rate` line for an observed `llm:*` target, comment-aligned like
474/// the rest of the block. Honest about what we measured: it cites the mean,
475/// never a peak.
476fn observed_rate_line(s: &TargetStats) -> String {
477    let mean = mean_per_min_floor(s);
478    let comment = if mean == 0 {
479        "# floor: single observation window, no mean to derive".to_owned()
480    } else {
481        format!("# headroom over your observed mean of ~{mean}/min")
482    };
483    pad_comment(
484        &format!("rate    = \"{}/min\"", llm_rate_per_min(s)),
485        &comment,
486    )
487}
488
489/// The observed-traffic comment for a target with discovery evidence.
490fn observed_comment(s: &TargetStats) -> String {
491    format!(
492        "observed: {} call{}, {} retr{}, ~{:.1}/min mean (.keel/discovery.db)",
493        s.calls,
494        plural(usize::try_from(s.calls).unwrap_or(usize::MAX)),
495        s.retries,
496        if s.retries == 1 { "y" } else { "ies" },
497        per_minute(s),
498    )
499}
500
501/// Mean calls/minute over the observed window; falls back to the raw call count
502/// when the window has zero span (a single observation).
503fn per_minute(s: &TargetStats) -> f64 {
504    #[expect(
505        clippy::cast_precision_loss,
506        reason = "call counts and ms spans are small; f64 is exact enough for a comment"
507    )]
508    let (calls, span_ms) = (
509        s.calls as f64,
510        (s.last_seen_ms - s.first_seen_ms).max(0) as f64,
511    );
512    if span_ms <= 0.0 {
513        calls
514    } else {
515        calls * 60_000.0 / span_ms
516    }
517}
518
519/// Pad `line` so a trailing `#` comment starts at [`COMMENT_COL`] (or one space
520/// past a longer line), keeping comment columns aligned and deterministic.
521fn pad_comment(line: &str, comment: &str) -> String {
522    let width = if line.len() < COMMENT_COL {
523        COMMENT_COL
524    } else {
525        line.len() + 1
526    };
527    format!("{line:<width$}{comment}")
528}
529
530/// `--diff`: what `keel init` would add/remove, as a target-name summary *and*
531/// an applyable patch (dx-spec §5, diffs as the lingua franca). Adds append
532/// whole evidence-cited blocks; removes drop `[target."…"]` tables no longer
533/// found in code; targets present on both sides are never touched, so user
534/// tuning and comments outside the changed blocks survive byte-for-byte. With
535/// no existing file the patch creates the whole generated keel.toml
536/// (`--- /dev/null`).
537fn diff(
538    toml_path: &Path,
539    scan: &ScanResult,
540    discovery: &[TargetStats],
541    generated: &[String],
542    content: &str,
543) -> Rendered {
544    let existing_text = match read_existing(toml_path) {
545        Ok(t) => t,
546        Err(e) => return config_error(&e),
547    };
548    let existing = match existing_text
549        .as_deref()
550        .map(|text| existing_targets(text, toml_path))
551        .transpose()
552    {
553        Ok(set) => set.unwrap_or_default(),
554        Err(e) => return config_error(&e),
555    };
556    let generated_set: BTreeSet<&str> = generated.iter().map(String::as_str).collect();
557    let added: Vec<String> = generated_set
558        .iter()
559        .filter(|t| !existing.contains(**t))
560        .map(|t| (*t).to_owned())
561        .collect();
562    let removed: Vec<String> = existing
563        .iter()
564        .filter(|t| !generated_set.contains(t.as_str()))
565        .cloned()
566        .collect();
567    let unchanged: Vec<String> = generated_set
568        .iter()
569        .filter(|t| existing.contains(**t))
570        .map(|t| (*t).to_owned())
571        .collect();
572
573    let ops = if existing_text.is_none() {
574        // No file yet: the patch creates the whole generated keel.toml, header
575        // comments included.
576        vec![PolicyOp::AppendBlock {
577            text: content.to_owned(),
578        }]
579    } else {
580        let by_target: BTreeMap<&str, &TargetStats> =
581            discovery.iter().map(|s| (s.target.as_str(), s)).collect();
582        let mut ops: Vec<PolicyOp> = removed
583            .iter()
584            .map(|t| PolicyOp::Remove {
585                path: PolicyPath::new(["target", t.as_str()]),
586            })
587            .collect();
588        ops.extend(added.iter().map(|t| PolicyOp::AppendBlock {
589            text: render_target_block(t, scan.targets.get(t), by_target.get(t.as_str()).copied()),
590        }));
591        ops
592    };
593    let proposal = match propose(existing_text.as_deref(), &ops) {
594        Ok(p) => p,
595        Err(e) => return config_error(&e.to_string()),
596    };
597
598    let mut human = String::from("keel \u{25b8} keel init --diff\n");
599    if added.is_empty() && removed.is_empty() {
600        human.push_str("  no changes: every discovered target is already in keel.toml.\n");
601    } else {
602        for t in &added {
603            let line = format!("  + [target.\"{t}\"]   (found in code, not in keel.toml)\n");
604            human.push_str(&line);
605        }
606        for t in &removed {
607            let line = format!("  - [target.\"{t}\"]   (in keel.toml, no longer found in code)\n");
608            human.push_str(&line);
609        }
610    }
611    if !proposal.patch.is_empty() {
612        human.push_str("\napply with `git apply` (or `patch -p1`):\n\n");
613        human.push_str(&proposal.patch);
614    }
615    let report = DiffReport {
616        added,
617        changes: proposal.changes,
618        patch: proposal.patch,
619        removed,
620        unchanged,
621    };
622    Rendered::ok(human, to_json(&report))
623}
624
625/// The current `keel.toml` text; `None` when the file does not exist (which
626/// selects the `/dev/null` creation patch).
627fn read_existing(toml_path: &Path) -> Result<Option<String>, String> {
628    if !toml_path.exists() {
629        return Ok(None);
630    }
631    std::fs::read_to_string(toml_path)
632        .map(Some)
633        .map_err(|e| format!("could not read {}: {e}", toml_path.display()))
634}
635
636/// The set of `[target."…"]` keys declared in an existing `keel.toml`.
637fn existing_targets(text: &str, toml_path: &Path) -> Result<BTreeSet<String>, String> {
638    let value: toml::Value = text
639        .parse()
640        .map_err(|e| format!("{} is not valid TOML: {e}", toml_path.display()))?;
641    let mut set = BTreeSet::new();
642    if let Some(table) = value.get("target").and_then(toml::Value::as_table) {
643        for key in table.keys() {
644            set.insert(key.clone());
645        }
646    }
647    Ok(set)
648}
649
650/// Append `.keel/` to `.gitignore` (creating it if absent) when not already
651/// ignored. Returns whether the file was changed.
652fn update_gitignore(project: &Path) -> std::io::Result<bool> {
653    let path = project.join(".gitignore");
654    if !path.exists() {
655        std::fs::write(&path, ".keel/\n")?;
656        return Ok(true);
657    }
658    let text = std::fs::read_to_string(&path)?;
659    let ignored = text
660        .lines()
661        .map(str::trim)
662        .any(|l| l == ".keel" || l == ".keel/");
663    if ignored {
664        return Ok(false);
665    }
666    let mut updated = text;
667    if !updated.ends_with('\n') && !updated.is_empty() {
668        updated.push('\n');
669    }
670    updated.push_str(".keel/\n");
671    std::fs::write(&path, updated)?;
672    Ok(true)
673}
674
675/// Whether `project` contains any `.py` file — used to distinguish "python3
676/// was not found" from "there was nothing to scan" in both `init` and `flows
677/// suggest`'s warnings.
678pub(crate) fn has_python_files(project: &Path) -> bool {
679    let mut found = Vec::new();
680    scan::collect_files(project, &["py"], &mut found);
681    !found.is_empty()
682}
683
684/// A config/usage failure (exit 2), rendered for both audiences.
685fn config_error(message: &str) -> Rendered {
686    #[derive(Serialize)]
687    struct ErrReport<'a> {
688        code: &'static str,
689        error: &'a str,
690    }
691    let human = format!("keel \u{25b8} KEEL-E001: {message}");
692    Rendered {
693        human,
694        json: to_json(&ErrReport {
695            code: "KEEL-E001",
696            error: message,
697        }),
698        exit: EXIT_USAGE,
699        to_stderr: true,
700    }
701    .with_exit(EXIT_USAGE)
702}
703
704/// `"s"` unless `n == 1` — shared by every report that pluralizes a count noun.
705pub(crate) fn plural(n: usize) -> &'static str {
706    if n == 1 { "" } else { "s" }
707}
708
709/// Today's date as `YYYY-MM-DD` (UTC). Only reached under `--stamp`, so the
710/// determinism guarantee (no wall clock by default) holds. Civil-date math is
711/// Hinnant's algorithm — no dependency, no locale.
712fn today_utc() -> String {
713    let secs = std::time::SystemTime::now()
714        .duration_since(std::time::UNIX_EPOCH)
715        .map_or(0, |d| d.as_secs());
716    let days = i64::try_from(secs / 86_400).unwrap_or(0);
717    let (y, m, d) = civil_from_days(days);
718    format!("{y:04}-{m:02}-{d:02}")
719}
720
721/// Convert days-since-epoch to `(year, month, day)` (proleptic Gregorian).
722fn civil_from_days(z: i64) -> (i64, u32, u32) {
723    let z = z + 719_468;
724    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
725    let doe = z - era * 146_097;
726    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
727    let y = yoe + era * 400;
728    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
729    let mp = (5 * doy + 2) / 153;
730    let d = doy - (153 * mp + 2) / 5 + 1;
731    let m = if mp < 10 { mp + 3 } else { mp - 9 };
732    let y = if m <= 2 { y + 1 } else { y };
733    #[expect(
734        clippy::cast_sign_loss,
735        clippy::cast_possible_truncation,
736        reason = "m,d in 1..=31"
737    )]
738    (y, m as u32, d as u32)
739}
740
741#[cfg(test)]
742mod tests {
743    use std::fs;
744
745    use keel_journal::ErrorClass;
746    use tempfile::TempDir;
747
748    use super::*;
749
750    /// A `TargetStats` for `llm:openai` with the given call count and observation
751    /// window; every other counter is inert (irrelevant to rate derivation).
752    fn llm_stats(calls: i64, first_seen_ms: i64, last_seen_ms: i64) -> TargetStats {
753        TargetStats {
754            target: "llm:openai".to_owned(),
755            calls,
756            attempts: calls,
757            retries: 0,
758            successes: calls,
759            failures: 0,
760            cache_hits: 0,
761            throttled: 0,
762            breaker_opens: 0,
763            total_latency_ms: 0,
764            max_latency_ms: 0,
765            first_seen_ms,
766            last_seen_ms,
767            last_error_class: None,
768            last_error_status: None,
769            not_retried: 0,
770            unwrapped_calls: 0,
771        }
772    }
773
774    fn host_scan() -> ScanResult {
775        let mut s = ScanResult {
776            files_scanned: 2,
777            python_available: true,
778            ..ScanResult::default()
779        };
780        // Reuse the private add via a fresh evidence set.
781        s.targets.insert(
782            "api.example.com".to_owned(),
783            TargetEvidence {
784                class: TargetClass::Host,
785                sightings: [scan::Sighting {
786                    file: "app.py".into(),
787                    line: 4,
788                }]
789                .into_iter()
790                .collect(),
791            },
792        );
793        s.targets.insert(
794            "llm:openai".to_owned(),
795            TargetEvidence {
796                class: TargetClass::Llm,
797                sightings: [scan::Sighting {
798                    file: "app.py".into(),
799                    line: 2,
800                }]
801                .into_iter()
802                .collect(),
803            },
804        );
805        s
806    }
807
808    #[test]
809    fn header_counts_and_no_date_by_default() {
810        let out = render_keel_toml(&host_scan(), &[], None);
811        assert!(
812            out.starts_with("# Generated by keel init from 2 static scans + 0 observed runs\n")
813        );
814        assert!(!out.contains("202"), "no date without --stamp");
815    }
816
817    #[test]
818    fn stamp_adds_a_date() {
819        let out = render_keel_toml(&host_scan(), &[], Some("2026-07-12"));
820        assert!(out.lines().next().unwrap().ends_with(" (2026-07-12)"));
821    }
822
823    #[test]
824    fn host_and_llm_blocks_cite_evidence() {
825        let out = render_keel_toml(&host_scan(), &[], None);
826        assert!(out.contains("[target.\"api.example.com\"]"));
827        assert!(out.contains("# seen in: app.py:4"));
828        assert!(out.contains("[target.\"llm:openai\"]"));
829        assert!(out.contains("# seen in: app.py:2"));
830        assert!(out.contains("cache   = { mode = \"dev\" }"));
831    }
832
833    #[test]
834    fn discovery_only_target_is_surfaced_with_observed_comment() {
835        let scan = ScanResult {
836            files_scanned: 1,
837            python_available: true,
838            ..ScanResult::default()
839        };
840        let stats = TargetStats {
841            target: "api.dynamic.com".to_owned(),
842            calls: 120,
843            attempts: 132,
844            retries: 12,
845            successes: 120,
846            failures: 0,
847            cache_hits: 0,
848            throttled: 0,
849            breaker_opens: 0,
850            total_latency_ms: 12_000,
851            max_latency_ms: 300,
852            first_seen_ms: 0,
853            last_seen_ms: 120_000, // 2 minutes → 60/min
854            last_error_class: None,
855            last_error_status: None,
856            not_retried: 0,
857            unwrapped_calls: 0,
858        };
859        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
860        assert!(out.contains("[target.\"api.dynamic.com\"]"));
861        assert!(out.contains("# seen only at runtime (.keel/discovery.db)"));
862        assert!(out.contains("# observed: 120 calls, 12 retries, ~60.0/min mean"));
863        // header now reports one observed run
864        assert!(out.contains("+ 1 observed run\n"));
865    }
866
867    #[test]
868    fn error_class_import_is_available() {
869        // Guards the keel_journal re-export used by status/doctor tests too.
870        let _ = ErrorClass::Http;
871    }
872
873    #[test]
874    fn default_body_matches_the_frozen_pack() {
875        // The hardcoded policy bodies must equal contracts/defaults.toml.
876        let defaults: toml::Value = include_str!("../contract/defaults.toml")
877            .parse()
878            .expect("defaults.toml parses");
879        let outbound = &defaults["defaults"]["outbound"];
880        assert_eq!(outbound["timeout"].as_str(), Some("30s"));
881        assert_eq!(outbound["retry"]["attempts"].as_integer(), Some(3));
882        assert_eq!(outbound["breaker"]["cooldown"].as_str(), Some("15s"));
883        let llm = &defaults["defaults"]["llm"];
884        assert_eq!(llm["timeout"].as_str(), Some("120s"));
885        assert_eq!(llm["retry"]["attempts"].as_integer(), Some(6));
886        assert_eq!(llm["breaker"]["cooldown"].as_str(), Some("30s"));
887        assert_eq!(llm["cache"]["mode"].as_str(), Some("dev"));
888        // and the bodies we emit reflect those values
889        assert!(policy_body(TargetClass::Host).contains("attempts = 3"));
890        assert!(policy_body(TargetClass::Host).contains("cooldown = \"15s\""));
891        assert!(policy_body(TargetClass::Llm).contains("attempts = 6"));
892        assert!(policy_body(TargetClass::Llm).contains("cooldown = \"30s\""));
893        assert!(policy_body(TargetClass::Llm).contains("mode = \"dev\""));
894    }
895
896    #[test]
897    fn civil_date_epoch_is_1970_01_01() {
898        assert_eq!(civil_from_days(0), (1970, 1, 1));
899        assert_eq!(civil_from_days(19_997), (2024, 10, 1));
900    }
901
902    // ---- item 3: evidence-tuned llm rate derivation ----
903
904    #[test]
905    fn round_up_clean_walks_the_1_2_5_series() {
906        assert_eq!(round_up_clean(0), 0);
907        assert_eq!(round_up_clean(1), 1);
908        assert_eq!(round_up_clean(3), 5);
909        assert_eq!(round_up_clean(6), 10);
910        assert_eq!(round_up_clean(11), 20);
911        assert_eq!(round_up_clean(50), 50);
912        assert_eq!(round_up_clean(60), 100);
913        assert_eq!(round_up_clean(123), 200);
914        assert_eq!(round_up_clean(300), 500);
915        assert_eq!(round_up_clean(501), 1_000);
916    }
917
918    #[test]
919    fn llm_rate_is_mean_times_three_rounded_up_to_a_clean_value() {
920        // 200 calls over a 2-min window → mean 100/min → ×3 = 300 → clean 500.
921        let s = llm_stats(200, 0, 120_000);
922        assert_eq!(mean_per_min_floor(&s), 100);
923        assert_eq!(llm_rate_per_min(&s), 500);
924    }
925
926    #[test]
927    fn llm_rate_floors_at_60_for_sparse_traffic() {
928        // 5 calls over 1 min → mean 5/min → ×3 = 15 → clean 20 → floored to 60.
929        let s = llm_stats(5, 0, 60_000);
930        assert_eq!(mean_per_min_floor(&s), 5);
931        assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
932    }
933
934    #[test]
935    fn llm_rate_zero_span_window_falls_back_to_floor() {
936        // Single-instant window (first_seen == last_seen): no mean derivable.
937        let s = llm_stats(500, 1_000, 1_000);
938        assert_eq!(mean_per_min_floor(&s), 0);
939        assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
940    }
941
942    #[test]
943    fn observed_llm_target_gets_an_active_rate_line() {
944        let scan = ScanResult {
945            files_scanned: 1,
946            python_available: true,
947            ..ScanResult::default()
948        };
949        let stats = llm_stats(200, 0, 120_000);
950        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
951
952        assert!(out.contains("[target.\"llm:openai\"]"));
953        assert!(out.contains("rate    = \"500/min\""));
954        assert!(out.contains("# headroom over your observed mean of ~100/min"));
955        // We measure a mean, never a peak — the word must never appear.
956        assert!(!out.contains("peak"));
957        // The rate line sits between breaker and cache.
958        let rate_at = out.find("rate    =").expect("rate line present");
959        let cache_at = out.find("cache   =").expect("cache line present");
960        assert!(rate_at < cache_at, "rate must precede cache");
961    }
962
963    #[test]
964    fn zero_span_llm_target_emits_floor_with_honest_comment() {
965        let scan = ScanResult {
966            files_scanned: 1,
967            python_available: true,
968            ..ScanResult::default()
969        };
970        let stats = llm_stats(9, 5_000, 5_000);
971        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
972        assert!(out.contains("rate    = \"60/min\""));
973        assert!(out.contains("# floor: single observation window, no mean to derive"));
974        assert!(!out.contains("peak"));
975    }
976
977    #[test]
978    fn observed_host_target_stays_comments_only() {
979        // Host targets never get an active rate, even with observed traffic.
980        let scan = ScanResult {
981            files_scanned: 1,
982            python_available: true,
983            ..ScanResult::default()
984        };
985        let stats = TargetStats {
986            target: "api.host.example".to_owned(),
987            ..llm_stats(200, 0, 120_000)
988        };
989        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
990        assert!(out.contains("[target.\"api.host.example\"]"));
991        assert!(
992            !out.contains("rate    ="),
993            "host targets must not emit an active rate line"
994        );
995    }
996
997    // ---- item 2: keel init write path ----
998
999    #[test]
1000    fn refuses_when_keel_toml_already_exists() {
1001        let dir = TempDir::new().unwrap();
1002        fs::write(dir.path().join("keel.toml"), "# hand-written\n").unwrap();
1003
1004        let r = run(dir.path(), InitOptions::default());
1005
1006        assert_eq!(r.exit, EXIT_USAGE);
1007        assert!(r.to_stderr);
1008        assert!(r.human.contains("already exists"));
1009        // The existing file is left untouched.
1010        assert_eq!(
1011            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1012            "# hand-written\n"
1013        );
1014    }
1015
1016    // ---- agents-cli layout redirection ----
1017
1018    /// An `agents-cli` project (manifest + agent dir at the root) gets its
1019    /// generated `keel.toml` written into the agent directory, not the
1020    /// project root — the generated Dockerfile only COPYs the agent dir.
1021    #[test]
1022    fn agents_cli_project_writes_keel_toml_into_the_agent_dir() {
1023        let dir = TempDir::new().unwrap();
1024        fs::create_dir(dir.path().join("app")).unwrap();
1025        fs::write(
1026            dir.path().join("agents-cli-manifest.yaml"),
1027            "schema_version: 1\nagent_directory: app\n",
1028        )
1029        .unwrap();
1030
1031        let r = run(dir.path(), InitOptions::default());
1032
1033        assert_eq!(r.exit, crate::EXIT_OK);
1034        assert!(!dir.path().join("keel.toml").exists(), "no root keel.toml");
1035        assert!(
1036            dir.path().join("app").join("keel.toml").exists(),
1037            "keel.toml lands in the agent directory"
1038        );
1039        assert_eq!(
1040            r.json["wrote"].as_str().unwrap(),
1041            dir.path()
1042                .join("app")
1043                .join("keel.toml")
1044                .display()
1045                .to_string()
1046        );
1047    }
1048
1049    /// A project with no `agents-cli-manifest.yaml` is unaffected: `keel.toml`
1050    /// still lands at the project root, byte-identical to the non-agents-cli
1051    /// goldens.
1052    #[test]
1053    fn non_agents_cli_project_writes_keel_toml_at_the_root() {
1054        let dir = TempDir::new().unwrap();
1055
1056        let r = run(dir.path(), InitOptions::default());
1057
1058        assert_eq!(r.exit, crate::EXIT_OK);
1059        assert!(dir.path().join("keel.toml").exists());
1060    }
1061
1062    /// The refuse-if-exists guard applies to the redirected path: an existing
1063    /// `keel.toml` inside the agent directory blocks the write even though the
1064    /// project root has none.
1065    #[test]
1066    fn agents_cli_refuses_when_the_redirected_path_already_exists() {
1067        let dir = TempDir::new().unwrap();
1068        fs::create_dir(dir.path().join("app")).unwrap();
1069        fs::write(
1070            dir.path().join("agents-cli-manifest.yaml"),
1071            "agent_directory: app\n",
1072        )
1073        .unwrap();
1074        fs::write(dir.path().join("app").join("keel.toml"), "# hand-written\n").unwrap();
1075
1076        let r = run(dir.path(), InitOptions::default());
1077
1078        assert_eq!(r.exit, EXIT_USAGE);
1079        assert!(r.human.contains("already exists"));
1080        assert_eq!(
1081            fs::read_to_string(dir.path().join("app").join("keel.toml")).unwrap(),
1082            "# hand-written\n"
1083        );
1084    }
1085
1086    /// `agents_cli_toml_path` itself: `None` for a non-agents-cli project.
1087    #[test]
1088    fn agents_cli_toml_path_is_none_without_a_manifest() {
1089        let dir = TempDir::new().unwrap();
1090        assert!(agents_cli_toml_path(dir.path()).is_none());
1091    }
1092
1093    /// CRITICAL regression: `agent_directory: ../elsewhere` must never escape
1094    /// `project`, even though the sibling directory it names genuinely exists
1095    /// on disk (the reviewer's exact repro). Both layers of the fix apply
1096    /// here — `agents_cli::find_agents_cli_layout` already rejects the `..`
1097    /// component, so `agents_cli_toml_path` sees no layout at all and returns
1098    /// `None` via `?`; this test pins that end-to-end outcome from `init`'s
1099    /// side rather than re-testing `agents_cli`'s parser directly.
1100    #[test]
1101    fn agents_cli_toml_path_is_none_when_agent_directory_escapes_the_project() {
1102        let root = TempDir::new().unwrap();
1103        let project = root.path().join("project");
1104        fs::create_dir(&project).unwrap();
1105        fs::create_dir(root.path().join("elsewhere")).unwrap();
1106        fs::write(
1107            project.join("agents-cli-manifest.yaml"),
1108            "agent_directory: ../elsewhere\n",
1109        )
1110        .unwrap();
1111
1112        assert!(agents_cli_toml_path(&project).is_none());
1113
1114        // And the same repro through the full `run` path never writes
1115        // outside `project`.
1116        let r = run(&project, InitOptions::default());
1117        assert_eq!(r.exit, crate::EXIT_OK);
1118        assert!(project.join("keel.toml").exists());
1119        assert!(!root.path().join("elsewhere").join("keel.toml").exists());
1120    }
1121
1122    #[test]
1123    fn diff_reports_added_and_removed_targets_precisely() {
1124        let dir = TempDir::new().unwrap();
1125        // JS scan (pure Rust, no python3) will find `api.example.com`.
1126        fs::write(
1127            dir.path().join("app.mjs"),
1128            "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1129        )
1130        .unwrap();
1131        // An existing keel.toml declares a target the scan will NOT find.
1132        fs::write(
1133            dir.path().join("keel.toml"),
1134            "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n",
1135        )
1136        .unwrap();
1137
1138        let r = run(
1139            dir.path(),
1140            InitOptions {
1141                diff: true,
1142                stamp: false,
1143                agents: false,
1144            },
1145        );
1146
1147        assert_eq!(r.exit, crate::EXIT_OK);
1148        assert_eq!(
1149            r.json["added"].as_array().unwrap(),
1150            &vec![serde_json::json!("api.example.com")]
1151        );
1152        assert_eq!(
1153            r.json["removed"].as_array().unwrap(),
1154            &vec![serde_json::json!("api.gone.example")]
1155        );
1156        assert!(r.json["unchanged"].as_array().unwrap().is_empty());
1157        assert!(r.human.contains("+ [target.\"api.example.com\"]"));
1158        assert!(r.human.contains("- [target.\"api.gone.example\"]"));
1159        // --diff never writes.
1160        assert_eq!(
1161            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1162            "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n"
1163        );
1164    }
1165
1166    /// dx-spec §5 (diffs as the lingua franca): `--diff` emits an applyable
1167    /// patch. Applying it removes stale blocks and appends evidence-cited new
1168    /// ones while user tuning outside the touched blocks survives byte-for-byte.
1169    #[test]
1170    fn diff_emits_an_applyable_patch_and_structured_changes() {
1171        let dir = TempDir::new().unwrap();
1172        fs::write(
1173            dir.path().join("app.mjs"),
1174            "// 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",
1175        )
1176        .unwrap();
1177        let old = "\
1178# hand-tuned: keep this comment
1179
1180[target.\"api.example.com\"]
1181timeout = \"9s\"   # user tuning survives
1182
1183[target.\"api.gone.example\"]  # stale
1184timeout = \"5s\"
1185";
1186        fs::write(dir.path().join("keel.toml"), old).unwrap();
1187
1188        let r = run(
1189            dir.path(),
1190            InitOptions {
1191                diff: true,
1192                stamp: false,
1193                agents: false,
1194            },
1195        );
1196
1197        assert_eq!(r.exit, crate::EXIT_OK);
1198        let patch = r.json["patch"].as_str().unwrap();
1199        assert!(
1200            patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"),
1201            "{patch}"
1202        );
1203        assert!(r.human.contains("apply with `git apply`"));
1204        assert!(
1205            r.human.contains(patch),
1206            "the human output carries the patch verbatim"
1207        );
1208
1209        let applied = crate::diff::apply_unified(old, patch).unwrap();
1210        let value: toml::Value = applied.parse().expect("applied file parses");
1211        assert!(value["target"].get("api.gone.example").is_none());
1212        assert!(value["target"].get("api.new.example").is_some());
1213        assert!(applied.contains("# hand-tuned: keep this comment"));
1214        assert!(applied.contains("timeout = \"9s\"   # user tuning survives"));
1215        // The added block is byte-identical to what a fresh init would write.
1216        assert!(applied.contains("[target.\"api.new.example\"]"));
1217        assert!(applied.contains("# seen in: app.mjs:3"));
1218
1219        // Structured hunks: one removal, one addition, sorted by path.
1220        let changes = r.json["changes"].as_array().unwrap();
1221        let paths: Vec<&str> = changes
1222            .iter()
1223            .map(|c| c["path"].as_str().unwrap())
1224            .collect();
1225        assert_eq!(
1226            paths,
1227            ["target.\"api.gone.example\"", "target.\"api.new.example\""]
1228        );
1229        assert!(changes[0]["after"].is_null());
1230        assert!(changes[1]["before"].is_null());
1231        // --diff never writes.
1232        assert_eq!(
1233            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1234            old
1235        );
1236    }
1237
1238    /// With no keel.toml the patch creates the whole generated file from
1239    /// `/dev/null`, byte-identical to what `keel init` would write.
1240    #[test]
1241    fn diff_without_existing_file_is_a_dev_null_creation_patch() {
1242        let dir = TempDir::new().unwrap();
1243        fs::write(
1244            dir.path().join("app.mjs"),
1245            "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1246        )
1247        .unwrap();
1248
1249        let r = run(
1250            dir.path(),
1251            InitOptions {
1252                diff: true,
1253                stamp: false,
1254                agents: false,
1255            },
1256        );
1257
1258        assert_eq!(r.exit, crate::EXIT_OK);
1259        let patch = r.json["patch"].as_str().unwrap();
1260        assert!(
1261            patch.starts_with("--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,"),
1262            "{patch}"
1263        );
1264        let scanned = scan::scan(dir.path());
1265        let expected = render_keel_toml(&scanned, &[], None);
1266        assert_eq!(crate::diff::apply_unified("", patch).unwrap(), expected);
1267        assert_eq!(
1268            r.json["added"].as_array().unwrap(),
1269            &vec![serde_json::json!("api.example.com")]
1270        );
1271    }
1272
1273    // ---- keel init --agents ----
1274
1275    #[test]
1276    fn agents_creates_then_is_idempotent() {
1277        let dir = TempDir::new().unwrap();
1278        let opts = InitOptions {
1279            agents: true,
1280            ..InitOptions::default()
1281        };
1282        let r1 = run(dir.path(), opts);
1283        assert_eq!(r1.exit, crate::EXIT_OK);
1284        assert!(r1.json["wrote"].as_bool().unwrap());
1285        let path = dir.path().join("AGENTS.md");
1286        let c1 = fs::read_to_string(&path).unwrap();
1287        assert!(c1.contains("## Keel (resilience & durable execution)"));
1288        assert!(c1.contains("keel doctor --json"));
1289
1290        // Re-run: nothing to change → already current, file byte-identical.
1291        let r2 = run(dir.path(), opts);
1292        assert!(r2.json["already_current"].as_bool().unwrap());
1293        assert_eq!(fs::read_to_string(&path).unwrap(), c1);
1294    }
1295
1296    #[test]
1297    fn splice_appends_then_replaces_region_without_duplicating() {
1298        let block = agents_block();
1299        // Append below existing prose.
1300        let (out, replaced, wrote) = splice_agents_block("# My project\n", &block);
1301        assert!(!replaced && wrote);
1302        assert!(out.starts_with("# My project\n\n"));
1303        assert!(out.contains(AGENTS_BEGIN) && out.contains(AGENTS_END));
1304        // Re-splicing the same block replaces in place and is a no-op write.
1305        let (out2, replaced2, wrote2) = splice_agents_block(&out, &block);
1306        assert!(replaced2 && !wrote2);
1307        assert_eq!(out2, out);
1308        assert_eq!(
1309            out2.matches(AGENTS_BEGIN).count(),
1310            1,
1311            "exactly one Keel block"
1312        );
1313    }
1314
1315    #[test]
1316    fn gitignore_is_created_when_absent() {
1317        let dir = TempDir::new().unwrap();
1318        assert!(update_gitignore(dir.path()).unwrap());
1319        assert_eq!(
1320            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1321            ".keel/\n"
1322        );
1323    }
1324
1325    #[test]
1326    fn gitignore_is_appended_when_keel_line_missing() {
1327        let dir = TempDir::new().unwrap();
1328        // No trailing newline: the appender must add one before `.keel/`.
1329        fs::write(dir.path().join(".gitignore"), "node_modules/\n*.log").unwrap();
1330
1331        assert!(update_gitignore(dir.path()).unwrap());
1332
1333        assert_eq!(
1334            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1335            "node_modules/\n*.log\n.keel/\n"
1336        );
1337    }
1338
1339    #[test]
1340    fn gitignore_is_a_noop_when_already_ignored() {
1341        let dir = TempDir::new().unwrap();
1342        let original = "build/\n.keel/\ncoverage/\n";
1343        fs::write(dir.path().join(".gitignore"), original).unwrap();
1344
1345        assert!(!update_gitignore(dir.path()).unwrap());
1346
1347        assert_eq!(
1348            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1349            original
1350        );
1351    }
1352
1353    /// CRITICAL containment test: `agents_cli_toml_path` rejects an absolute
1354    /// agent directory using canonicalization (layer 2), not Path::starts_with
1355    /// (which is purely syntactic). This is defense in depth: even though a
1356    /// manifest with `agent_directory: /etc` contains no `..` component (so
1357    /// layer 1 in agents_cli.rs does not reject it), `canonicalize` resolves it
1358    /// to the actual `/etc` on disk, which fails the starts_with check and
1359    /// returns None. If layer 2 were removed and only layer 1 remained, this
1360    /// would escape the project.
1361    #[test]
1362    fn agents_cli_toml_path_rejects_absolute_path_agent_directory() {
1363        let root = TempDir::new().unwrap();
1364        let project = root.path().join("project");
1365        fs::create_dir(&project).unwrap();
1366        let outside = root.path().join("outside");
1367        fs::create_dir(&outside).unwrap();
1368
1369        fs::write(
1370            project.join("agents-cli-manifest.yaml"),
1371            // Using the actual outside TempDir path (absolute, no ..) — layer 1
1372            // does NOT reject this because there's no ParentDir component.
1373            format!("agent_directory: {}\n", outside.display()),
1374        )
1375        .unwrap();
1376
1377        // agents_cli_toml_path should return None because canonicalization
1378        // resolves the absolute path and discovers it's outside project.
1379        assert!(agents_cli_toml_path(&project).is_none());
1380
1381        // End-to-end through run: writes to project root, never to outside.
1382        let r = run(&project, InitOptions::default());
1383        assert_eq!(r.exit, crate::EXIT_OK);
1384        assert!(project.join("keel.toml").exists());
1385        assert!(!outside.join("keel.toml").exists());
1386    }
1387
1388    /// CRITICAL containment test: `agents_cli_toml_path` rejects a symlink
1389    /// agent directory that points outside the project. This is defense in
1390    /// depth: the manifest's `agent_directory` is a valid relative path inside
1391    /// `project`, but if it's a symlink pointing outside, `canonicalize` will
1392    /// resolve it to the actual target and fail the starts_with check. Layer 1
1393    /// (agents_cli.rs rejecting `..`) does not catch this — only canonicalization
1394    /// (layer 2) does.
1395    #[cfg(unix)]
1396    #[test]
1397    fn agents_cli_toml_path_rejects_symlink_escape_to_outside_project() {
1398        use std::os::unix::fs as unix_fs;
1399
1400        let root = TempDir::new().unwrap();
1401        let project = root.path().join("project");
1402        fs::create_dir(&project).unwrap();
1403        let outside = root.path().join("outside");
1404        fs::create_dir(&outside).unwrap();
1405
1406        // Create a symlink inside project that points to outside.
1407        let symlink = project.join("app");
1408        unix_fs::symlink(&outside, &symlink).unwrap();
1409
1410        fs::write(
1411            project.join("agents-cli-manifest.yaml"),
1412            "agent_directory: app\n",
1413        )
1414        .unwrap();
1415
1416        // agents_cli_toml_path should return None because canonicalize resolves
1417        // the symlink to outside and fails the starts_with check.
1418        assert!(agents_cli_toml_path(&project).is_none());
1419
1420        // End-to-end through run: writes to project root, never to outside.
1421        let r = run(&project, InitOptions::default());
1422        assert_eq!(r.exit, crate::EXIT_OK);
1423        assert!(project.join("keel.toml").exists());
1424        assert!(!outside.join("keel.toml").exists());
1425    }
1426}