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::diff::{ChangeHunk, PolicyOp, PolicyPath, propose};
21use crate::render::to_json;
22use crate::scan::{ScanResult, TargetClass, TargetEvidence};
23use crate::{EXIT_USAGE, Rendered, evidence, scan};
24
25/// Column at which trailing `#` comments begin, when the line is shorter.
26const COMMENT_COL: usize = 37;
27
28/// Options parsed from the `keel init` flags.
29#[derive(Debug, Clone, Copy, Default)]
30pub struct InitOptions {
31    /// Preview changes against an existing `keel.toml` instead of writing.
32    pub diff: bool,
33    /// Stamp today's date into the header (off by default for determinism).
34    pub stamp: bool,
35    /// Drop the Keel section into `AGENTS.md` (dx-spec §5) instead of generating
36    /// a policy — so every future coding-agent session inherits Keel context.
37    pub agents: bool,
38}
39
40/// Marker fencing the Keel-managed region in `AGENTS.md`, so a re-run updates the
41/// section in place (idempotent) instead of appending a duplicate.
42const AGENTS_BEGIN: &str = "<!-- keel:begin -->";
43const AGENTS_END: &str = "<!-- keel:end -->";
44
45/// The concise, agent-facing Keel section (dx-spec §5). Deterministic: no dates
46/// or versions, so an agent can diff it. Bytes are golden-tested.
47const AGENTS_SNIPPET: &str = "\
48## Keel (resilience & durable execution)
49
50This project uses **Keel** for production-grade resilience (retries, timeouts,
51circuit breakers, rate limits) and opt-in durable flows — applied at intercepted
52call boundaries with **zero code changes**. Policy lives in one file: `keel.toml`.
53
54Before changing any resilience behavior:
55- Run `keel doctor --json` to see what is wrapped, what is not, and why.
56- Propose policy edits as a diff: `keel init --diff` shows adds/removes from evidence.
57- Every command has a `--json` twin with deterministic, sorted output — diff it to detect change.
58
59Useful commands (all support `--json`):
60- `keel status` — coverage, retries saved, breaker events, resumable flows.
61- `keel explain <KEEL-E0NN>` — the exact what/why/next for an error code.
62- `keel flows` / `keel trace <flow>` — durable (Tier 2) flow state and step ledger.
63- `keel mcp` — the same surfaces as MCP tools over stdio (get_status,
64  get_doctor_report, propose_policy, get_trace, list_flows, explain_error).
65
66Do not hand-write retry loops or backoff around calls Keel already wraps; edit
67`keel.toml` instead. Uninstalling Keel removes the behavior and nothing else —
68the code runs identically without it.";
69
70/// The full fenced block written into `AGENTS.md` (begin marker, snippet, end
71/// marker, trailing newline). Public so the golden test can pin its bytes.
72#[must_use]
73pub fn agents_block() -> String {
74    format!("{AGENTS_BEGIN}\n{AGENTS_SNIPPET}\n{AGENTS_END}\n")
75}
76
77/// The machine twin of `--agents`.
78#[derive(Debug, Serialize)]
79struct AgentsReport {
80    already_current: bool,
81    path: String,
82    updated: bool,
83    wrote: bool,
84}
85
86/// `keel init --agents`: create/update the Keel section in `AGENTS.md`. Idempotent
87/// — a marker-fenced region is replaced in place on re-run, so it never appends a
88/// duplicate and reflects the current snippet exactly.
89fn run_agents(project: &Path) -> Rendered {
90    let path = project.join("AGENTS.md");
91    let existing = match std::fs::read_to_string(&path) {
92        Ok(text) => text,
93        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
94        Err(e) => return config_error(&format!("could not read {}: {e}", path.display())),
95    };
96    let block = agents_block();
97    let (new_content, replaced, wrote) = splice_agents_block(&existing, &block);
98    let already_current = !wrote;
99    // `updated` = we replaced an existing region AND its bytes changed; a fresh
100    // create is `wrote` but not `updated`, and a no-op re-run is neither.
101    let updated = wrote && replaced;
102    if wrote && let Err(e) = std::fs::write(&path, &new_content) {
103        return config_error(&format!("could not write {}: {e}", path.display()));
104    }
105    let verb = if already_current {
106        "already current"
107    } else if updated {
108        "updated the Keel section in"
109    } else {
110        "wrote the Keel section to"
111    };
112    let human = format!("keel \u{25b8} {verb} {}", path.display());
113    let report = AgentsReport {
114        already_current,
115        path: path.display().to_string(),
116        updated,
117        wrote,
118    };
119    Rendered::ok(human, to_json(&report))
120}
121
122/// Compute the new `AGENTS.md` content given the existing text and the desired
123/// block. Returns `(content, replaced_existing, needs_write)`. Pure — unit
124/// tested. Replaces a marker-fenced region in place; else appends (or creates).
125fn splice_agents_block(existing: &str, block: &str) -> (String, bool, bool) {
126    if let (Some(start), Some(end_idx)) = (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
127        let end = end_idx + AGENTS_END.len();
128        // Consume a single trailing newline after the end marker so re-splicing
129        // is stable (the block already ends in one).
130        let tail_start = existing[end..].strip_prefix('\n').map_or(end, |_| end + 1);
131        let mut out = String::with_capacity(existing.len());
132        out.push_str(&existing[..start]);
133        out.push_str(block);
134        out.push_str(&existing[tail_start..]);
135        let needs_write = out != existing;
136        return (out, true, needs_write);
137    }
138    if existing.is_empty() {
139        return (block.to_owned(), false, true);
140    }
141    let mut out = existing.to_owned();
142    if !out.ends_with('\n') {
143        out.push('\n');
144    }
145    out.push('\n');
146    out.push_str(block);
147    (out, false, true)
148}
149
150/// The machine twin of a write.
151#[derive(Debug, Serialize)]
152struct WroteReport {
153    gitignore_updated: bool,
154    observed_runs: u32,
155    static_scans: usize,
156    targets: Vec<String>,
157    wrote: String,
158}
159
160/// The machine twin of `--diff`: the target-name summary plus the applyable
161/// forms (dx-spec §5, diffs as the lingua franca) — `patch` for `git apply`,
162/// `changes` for structured consumption.
163#[derive(Debug, Serialize)]
164struct DiffReport {
165    added: Vec<String>,
166    changes: Vec<ChangeHunk>,
167    patch: String,
168    removed: Vec<String>,
169    unchanged: Vec<String>,
170}
171
172/// Run `keel init` for `project`.
173pub fn run(project: &Path, opts: InitOptions) -> Rendered {
174    if opts.agents {
175        return run_agents(project);
176    }
177    let scan = scan::scan(project);
178    let discovery = match evidence::read_discovery(project) {
179        Ok(d) => d,
180        Err(e) => return config_error(&e),
181    };
182
183    let content = render_keel_toml(&scan, &discovery, opts.stamp.then(today_utc).as_deref());
184    let targets = merged_targets(&scan, &discovery);
185
186    let toml_path = evidence::keel_toml(project);
187    if opts.diff {
188        return diff(&toml_path, &scan, &discovery, &targets, &content);
189    }
190    if toml_path.exists() {
191        return config_error(&format!(
192            "{} already exists. Run `keel init --diff` to preview changes, or edit it directly.",
193            toml_path.display()
194        ));
195    }
196
197    if let Err(e) = std::fs::write(&toml_path, &content) {
198        return config_error(&format!("could not write {}: {e}", toml_path.display()));
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/// The set of targets the generated file will contain: static findings unioned
236/// with discovery-only targets (runtime caught what the scan missed).
237fn merged_targets(scan: &ScanResult, discovery: &[TargetStats]) -> Vec<String> {
238    let mut set: BTreeSet<String> = scan.targets.keys().cloned().collect();
239    for stats in discovery {
240        set.insert(stats.target.clone());
241    }
242    set.into_iter().collect()
243}
244
245/// Render the full `keel.toml` text. Pure and deterministic — the snapshot
246/// tests pin its bytes.
247pub fn render_keel_toml(
248    scan: &ScanResult,
249    discovery: &[TargetStats],
250    stamp: Option<&str>,
251) -> String {
252    let by_target: BTreeMap<&str, &TargetStats> =
253        discovery.iter().map(|s| (s.target.as_str(), s)).collect();
254    let observed_runs = u32::from(!discovery.is_empty());
255
256    let mut out = String::new();
257    let date = stamp.map_or_else(String::new, |d| format!(" ({d})"));
258    let header = format!(
259        "# Generated by keel init from {} static scan{} + {} observed run{}{}\n",
260        scan.files_scanned,
261        plural(scan.files_scanned),
262        observed_runs,
263        plural(observed_runs as usize),
264        date,
265    );
266    out.push_str(&header);
267    out.push_str(
268        "# Every entry below was found in YOUR code. Delete anything; defaults still apply.\n",
269    );
270
271    for target in merged_targets(scan, discovery) {
272        out.push('\n');
273        let evidence = scan.targets.get(&target);
274        let stats = by_target.get(target.as_str()).copied();
275        out.push_str(&render_target_block(&target, evidence, stats));
276    }
277    out
278}
279
280/// Render one `[target."…"]` block (no leading blank line): header + evidence
281/// comment(s) + policy body. Shared by the full render and the `--diff` add
282/// hunks, so an added block in the patch is byte-identical to what a fresh
283/// `keel init` would write.
284fn render_target_block(
285    target: &str,
286    evidence: Option<&TargetEvidence>,
287    stats: Option<&TargetStats>,
288) -> String {
289    let mut buf = String::new();
290    let out = &mut buf;
291    let header = format!("[target.\"{target}\"]");
292    let seen_comment = evidence.map(|e| {
293        let labels = e
294            .sightings
295            .iter()
296            .map(scan::Sighting::label)
297            .collect::<Vec<_>>()
298            .join(", ");
299        format!("# seen in: {labels}")
300    });
301    let comment =
302        seen_comment.unwrap_or_else(|| "# seen only at runtime (.keel/discovery.db)".to_owned());
303    out.push_str(&pad_comment(&header, &comment));
304    out.push('\n');
305
306    if let Some(s) = stats {
307        let observed = format!("# {}\n", observed_comment(s));
308        out.push_str(&observed);
309    }
310
311    let class = evidence.map_or_else(
312        || {
313            if target.starts_with("llm:") {
314                TargetClass::Llm
315            } else {
316                TargetClass::Host
317            }
318        },
319        |e| e.class,
320    );
321    match (class, stats) {
322        // dx-spec §1 flagship: an observed `llm:*` target earns an *active* rate
323        // limit tuned from its own evidence, inserted between breaker and cache.
324        (TargetClass::Llm, Some(s)) => {
325            out.push_str(LLM_BODY_HEAD);
326            out.push_str(&observed_rate_line(s));
327            out.push('\n');
328            out.push_str(LLM_CACHE_LINE);
329        }
330        // Host targets stay comments-only even with observed traffic: imposing an
331        // active throttle on general outbound HTTP without an explicit opt-in
332        // would be a Level-0 surprise (dx-spec §1 hard rules). An evidence-tuned
333        // host rate is deliberately out of scope for v0.1.
334        _ => out.push_str(&policy_body(class)),
335    }
336    buf
337}
338
339/// Outbound-host policy body. Mirrors the frozen smart-defaults pack
340/// (`contracts/defaults.toml` outbound); a test asserts they stay in sync.
341const HOST_BODY: &str = concat!(
342    "timeout = \"30s\"\n",
343    "retry   = { attempts = 3, schedule = \"exp(200ms, x2, max 30s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
344    "breaker = { failures = 5, cooldown = \"15s\" }\n",
345);
346
347/// The LLM body up to and including the breaker line — everything that precedes
348/// the *optional* evidence-derived `rate` line. Mirrors `contracts/defaults.toml`
349/// llm pack.
350const LLM_BODY_HEAD: &str = concat!(
351    "timeout = \"120s\"\n",
352    "retry   = { attempts = 6, schedule = \"exp(500ms, x2, max 60s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
353    "breaker = { failures = 5, cooldown = \"30s\" }\n",
354);
355
356/// The LLM dev-cache line — always the last line of an `llm:*` block.
357const LLM_CACHE_LINE: &str =
358    "cache   = { mode = \"dev\" }          # dev-loop cache; disabled when KEEL_ENV=prod\n";
359
360/// Floor for an observed `llm:*` target's active rate, in calls/min. Below this
361/// the derived headroom is noise (LLM traffic is bursty), so we never emit an
362/// active limit under 60/min — also the value used when the observation window
363/// is a single instant (no mean to derive).
364const LLM_RATE_FLOOR_PER_MIN: u64 = 60;
365
366/// Headroom multiplier over the observed MEAN rate. The discovery store keeps
367/// only `calls` + `first_seen_ms`/`last_seen_ms` — it measures a mean, never a
368/// per-minute *peak* — so we scale the mean up generously to leave room for the
369/// peaks we did not measure. NEVER describe the result as a peak.
370const LLM_RATE_HEADROOM: u64 = 3;
371
372/// The policy body for a class *without* any evidence-derived keys. Values
373/// mirror the frozen smart-defaults pack (`contracts/defaults.toml`); a test
374/// asserts they stay in sync. Writing them out (rather than relying on the
375/// invisible defaults) makes the file self-documenting — the DX promise that
376/// "the generated file is the docs".
377fn policy_body(class: TargetClass) -> String {
378    match class {
379        TargetClass::Host => HOST_BODY.to_owned(),
380        TargetClass::Llm => format!("{LLM_BODY_HEAD}{LLM_CACHE_LINE}"),
381    }
382}
383
384/// The observed MEAN calls/minute as an integer floor, or `0` when the window is
385/// a single instant (`first_seen_ms == last_seen_ms`). Pure integer math keeps
386/// the output byte-deterministic. Basis for both the derived rate and its
387/// comment.
388fn mean_per_min_floor(s: &TargetStats) -> u64 {
389    let span_ms = u64::try_from((s.last_seen_ms - s.first_seen_ms).max(0)).unwrap_or(u64::MAX);
390    if span_ms == 0 {
391        return 0;
392    }
393    let calls = u64::try_from(s.calls.max(0)).unwrap_or(u64::MAX);
394    calls.saturating_mul(60_000) / span_ms
395}
396
397/// Derive an active per-minute rate limit for an observed `llm:*` target:
398/// `mean × LLM_RATE_HEADROOM`, [rounded up to a clean value](round_up_clean),
399/// clamped to a floor of [`LLM_RATE_FLOOR_PER_MIN`]. A single-instant window has
400/// no derivable mean, so it falls back to the floor. Deterministic integer math.
401fn llm_rate_per_min(s: &TargetStats) -> u64 {
402    let mean = mean_per_min_floor(s);
403    if mean == 0 {
404        return LLM_RATE_FLOOR_PER_MIN;
405    }
406    round_up_clean(mean.saturating_mul(LLM_RATE_HEADROOM)).max(LLM_RATE_FLOOR_PER_MIN)
407}
408
409/// Round `n` UP to the next "clean" value in the 1-2-5 decade series
410/// (…10, 20, 50, 100, 200, 500, 1000…) — the standard nice-number ceiling.
411/// `round_up_clean(0) == 0`.
412fn round_up_clean(n: u64) -> u64 {
413    if n == 0 {
414        return 0;
415    }
416    let mut unit = 1_u64;
417    loop {
418        for m in [1_u64, 2, 5] {
419            let candidate = m.saturating_mul(unit);
420            if candidate >= n {
421                return candidate;
422            }
423        }
424        match unit.checked_mul(10) {
425            Some(next) => unit = next,
426            None => return u64::MAX,
427        }
428    }
429}
430
431/// The active `rate` line for an observed `llm:*` target, comment-aligned like
432/// the rest of the block. Honest about what we measured: it cites the mean,
433/// never a peak.
434fn observed_rate_line(s: &TargetStats) -> String {
435    let mean = mean_per_min_floor(s);
436    let comment = if mean == 0 {
437        "# floor: single observation window, no mean to derive".to_owned()
438    } else {
439        format!("# headroom over your observed mean of ~{mean}/min")
440    };
441    pad_comment(
442        &format!("rate    = \"{}/min\"", llm_rate_per_min(s)),
443        &comment,
444    )
445}
446
447/// The observed-traffic comment for a target with discovery evidence.
448fn observed_comment(s: &TargetStats) -> String {
449    format!(
450        "observed: {} call{}, {} retr{}, ~{:.1}/min mean (.keel/discovery.db)",
451        s.calls,
452        plural(usize::try_from(s.calls).unwrap_or(usize::MAX)),
453        s.retries,
454        if s.retries == 1 { "y" } else { "ies" },
455        per_minute(s),
456    )
457}
458
459/// Mean calls/minute over the observed window; falls back to the raw call count
460/// when the window has zero span (a single observation).
461fn per_minute(s: &TargetStats) -> f64 {
462    #[expect(
463        clippy::cast_precision_loss,
464        reason = "call counts and ms spans are small; f64 is exact enough for a comment"
465    )]
466    let (calls, span_ms) = (
467        s.calls as f64,
468        (s.last_seen_ms - s.first_seen_ms).max(0) as f64,
469    );
470    if span_ms <= 0.0 {
471        calls
472    } else {
473        calls * 60_000.0 / span_ms
474    }
475}
476
477/// Pad `line` so a trailing `#` comment starts at [`COMMENT_COL`] (or one space
478/// past a longer line), keeping comment columns aligned and deterministic.
479fn pad_comment(line: &str, comment: &str) -> String {
480    let width = if line.len() < COMMENT_COL {
481        COMMENT_COL
482    } else {
483        line.len() + 1
484    };
485    format!("{line:<width$}{comment}")
486}
487
488/// `--diff`: what `keel init` would add/remove, as a target-name summary *and*
489/// an applyable patch (dx-spec §5, diffs as the lingua franca). Adds append
490/// whole evidence-cited blocks; removes drop `[target."…"]` tables no longer
491/// found in code; targets present on both sides are never touched, so user
492/// tuning and comments outside the changed blocks survive byte-for-byte. With
493/// no existing file the patch creates the whole generated keel.toml
494/// (`--- /dev/null`).
495fn diff(
496    toml_path: &Path,
497    scan: &ScanResult,
498    discovery: &[TargetStats],
499    generated: &[String],
500    content: &str,
501) -> Rendered {
502    let existing_text = match read_existing(toml_path) {
503        Ok(t) => t,
504        Err(e) => return config_error(&e),
505    };
506    let existing = match existing_text
507        .as_deref()
508        .map(|text| existing_targets(text, toml_path))
509        .transpose()
510    {
511        Ok(set) => set.unwrap_or_default(),
512        Err(e) => return config_error(&e),
513    };
514    let generated_set: BTreeSet<&str> = generated.iter().map(String::as_str).collect();
515    let added: Vec<String> = generated_set
516        .iter()
517        .filter(|t| !existing.contains(**t))
518        .map(|t| (*t).to_owned())
519        .collect();
520    let removed: Vec<String> = existing
521        .iter()
522        .filter(|t| !generated_set.contains(t.as_str()))
523        .cloned()
524        .collect();
525    let unchanged: Vec<String> = generated_set
526        .iter()
527        .filter(|t| existing.contains(**t))
528        .map(|t| (*t).to_owned())
529        .collect();
530
531    let ops = if existing_text.is_none() {
532        // No file yet: the patch creates the whole generated keel.toml, header
533        // comments included.
534        vec![PolicyOp::AppendBlock {
535            text: content.to_owned(),
536        }]
537    } else {
538        let by_target: BTreeMap<&str, &TargetStats> =
539            discovery.iter().map(|s| (s.target.as_str(), s)).collect();
540        let mut ops: Vec<PolicyOp> = removed
541            .iter()
542            .map(|t| PolicyOp::Remove {
543                path: PolicyPath::new(["target", t.as_str()]),
544            })
545            .collect();
546        ops.extend(added.iter().map(|t| PolicyOp::AppendBlock {
547            text: render_target_block(t, scan.targets.get(t), by_target.get(t.as_str()).copied()),
548        }));
549        ops
550    };
551    let proposal = match propose(existing_text.as_deref(), &ops) {
552        Ok(p) => p,
553        Err(e) => return config_error(&e.to_string()),
554    };
555
556    let mut human = String::from("keel \u{25b8} keel init --diff\n");
557    if added.is_empty() && removed.is_empty() {
558        human.push_str("  no changes: every discovered target is already in keel.toml.\n");
559    } else {
560        for t in &added {
561            let line = format!("  + [target.\"{t}\"]   (found in code, not in keel.toml)\n");
562            human.push_str(&line);
563        }
564        for t in &removed {
565            let line = format!("  - [target.\"{t}\"]   (in keel.toml, no longer found in code)\n");
566            human.push_str(&line);
567        }
568    }
569    if !proposal.patch.is_empty() {
570        human.push_str("\napply with `git apply` (or `patch -p1`):\n\n");
571        human.push_str(&proposal.patch);
572    }
573    let report = DiffReport {
574        added,
575        changes: proposal.changes,
576        patch: proposal.patch,
577        removed,
578        unchanged,
579    };
580    Rendered::ok(human, to_json(&report))
581}
582
583/// The current `keel.toml` text; `None` when the file does not exist (which
584/// selects the `/dev/null` creation patch).
585fn read_existing(toml_path: &Path) -> Result<Option<String>, String> {
586    if !toml_path.exists() {
587        return Ok(None);
588    }
589    std::fs::read_to_string(toml_path)
590        .map(Some)
591        .map_err(|e| format!("could not read {}: {e}", toml_path.display()))
592}
593
594/// The set of `[target."…"]` keys declared in an existing `keel.toml`.
595fn existing_targets(text: &str, toml_path: &Path) -> Result<BTreeSet<String>, String> {
596    let value: toml::Value = text
597        .parse()
598        .map_err(|e| format!("{} is not valid TOML: {e}", toml_path.display()))?;
599    let mut set = BTreeSet::new();
600    if let Some(table) = value.get("target").and_then(toml::Value::as_table) {
601        for key in table.keys() {
602            set.insert(key.clone());
603        }
604    }
605    Ok(set)
606}
607
608/// Append `.keel/` to `.gitignore` (creating it if absent) when not already
609/// ignored. Returns whether the file was changed.
610fn update_gitignore(project: &Path) -> std::io::Result<bool> {
611    let path = project.join(".gitignore");
612    if !path.exists() {
613        std::fs::write(&path, ".keel/\n")?;
614        return Ok(true);
615    }
616    let text = std::fs::read_to_string(&path)?;
617    let ignored = text
618        .lines()
619        .map(str::trim)
620        .any(|l| l == ".keel" || l == ".keel/");
621    if ignored {
622        return Ok(false);
623    }
624    let mut updated = text;
625    if !updated.ends_with('\n') && !updated.is_empty() {
626        updated.push('\n');
627    }
628    updated.push_str(".keel/\n");
629    std::fs::write(&path, updated)?;
630    Ok(true)
631}
632
633/// Whether `project` contains any `.py` file — used to distinguish "python3
634/// was not found" from "there was nothing to scan" in both `init` and `flows
635/// suggest`'s warnings.
636pub(crate) fn has_python_files(project: &Path) -> bool {
637    fn walk(dir: &Path) -> bool {
638        let Ok(entries) = std::fs::read_dir(dir) else {
639            return false;
640        };
641        for entry in entries.flatten() {
642            let path = entry.path();
643            let name = entry.file_name();
644            let name = name.to_string_lossy();
645            if path.is_dir() {
646                if scan::SKIP_DIRS.contains(&name.as_ref()) || name.starts_with('.') {
647                    continue;
648                }
649                if walk(&path) {
650                    return true;
651                }
652            } else if path.extension().and_then(|e| e.to_str()) == Some("py") {
653                return true;
654            }
655        }
656        false
657    }
658    walk(project)
659}
660
661/// A config/usage failure (exit 2), rendered for both audiences.
662fn config_error(message: &str) -> Rendered {
663    #[derive(Serialize)]
664    struct ErrReport<'a> {
665        code: &'static str,
666        error: &'a str,
667    }
668    let human = format!("keel \u{25b8} KEEL-E001: {message}");
669    Rendered {
670        human,
671        json: to_json(&ErrReport {
672            code: "KEEL-E001",
673            error: message,
674        }),
675        exit: EXIT_USAGE,
676        to_stderr: true,
677    }
678    .with_exit(EXIT_USAGE)
679}
680
681/// `"s"` unless `n == 1` — shared by every report that pluralizes a count noun.
682pub(crate) fn plural(n: usize) -> &'static str {
683    if n == 1 { "" } else { "s" }
684}
685
686/// Today's date as `YYYY-MM-DD` (UTC). Only reached under `--stamp`, so the
687/// determinism guarantee (no wall clock by default) holds. Civil-date math is
688/// Hinnant's algorithm — no dependency, no locale.
689fn today_utc() -> String {
690    let secs = std::time::SystemTime::now()
691        .duration_since(std::time::UNIX_EPOCH)
692        .map_or(0, |d| d.as_secs());
693    let days = i64::try_from(secs / 86_400).unwrap_or(0);
694    let (y, m, d) = civil_from_days(days);
695    format!("{y:04}-{m:02}-{d:02}")
696}
697
698/// Convert days-since-epoch to `(year, month, day)` (proleptic Gregorian).
699fn civil_from_days(z: i64) -> (i64, u32, u32) {
700    let z = z + 719_468;
701    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
702    let doe = z - era * 146_097;
703    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
704    let y = yoe + era * 400;
705    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
706    let mp = (5 * doy + 2) / 153;
707    let d = doy - (153 * mp + 2) / 5 + 1;
708    let m = if mp < 10 { mp + 3 } else { mp - 9 };
709    let y = if m <= 2 { y + 1 } else { y };
710    #[expect(
711        clippy::cast_sign_loss,
712        clippy::cast_possible_truncation,
713        reason = "m,d in 1..=31"
714    )]
715    (y, m as u32, d as u32)
716}
717
718#[cfg(test)]
719mod tests {
720    use std::fs;
721
722    use keel_journal::ErrorClass;
723    use tempfile::TempDir;
724
725    use super::*;
726
727    /// A `TargetStats` for `llm:openai` with the given call count and observation
728    /// window; every other counter is inert (irrelevant to rate derivation).
729    fn llm_stats(calls: i64, first_seen_ms: i64, last_seen_ms: i64) -> TargetStats {
730        TargetStats {
731            target: "llm:openai".to_owned(),
732            calls,
733            attempts: calls,
734            retries: 0,
735            successes: calls,
736            failures: 0,
737            cache_hits: 0,
738            throttled: 0,
739            breaker_opens: 0,
740            total_latency_ms: 0,
741            max_latency_ms: 0,
742            first_seen_ms,
743            last_seen_ms,
744            last_error_class: None,
745            last_error_status: None,
746            not_retried: 0,
747            unwrapped_calls: 0,
748        }
749    }
750
751    fn host_scan() -> ScanResult {
752        let mut s = ScanResult {
753            files_scanned: 2,
754            python_available: true,
755            ..ScanResult::default()
756        };
757        // Reuse the private add via a fresh evidence set.
758        s.targets.insert(
759            "api.example.com".to_owned(),
760            TargetEvidence {
761                class: TargetClass::Host,
762                sightings: [scan::Sighting {
763                    file: "app.py".into(),
764                    line: 4,
765                }]
766                .into_iter()
767                .collect(),
768            },
769        );
770        s.targets.insert(
771            "llm:openai".to_owned(),
772            TargetEvidence {
773                class: TargetClass::Llm,
774                sightings: [scan::Sighting {
775                    file: "app.py".into(),
776                    line: 2,
777                }]
778                .into_iter()
779                .collect(),
780            },
781        );
782        s
783    }
784
785    #[test]
786    fn header_counts_and_no_date_by_default() {
787        let out = render_keel_toml(&host_scan(), &[], None);
788        assert!(
789            out.starts_with("# Generated by keel init from 2 static scans + 0 observed runs\n")
790        );
791        assert!(!out.contains("202"), "no date without --stamp");
792    }
793
794    #[test]
795    fn stamp_adds_a_date() {
796        let out = render_keel_toml(&host_scan(), &[], Some("2026-07-12"));
797        assert!(out.lines().next().unwrap().ends_with(" (2026-07-12)"));
798    }
799
800    #[test]
801    fn host_and_llm_blocks_cite_evidence() {
802        let out = render_keel_toml(&host_scan(), &[], None);
803        assert!(out.contains("[target.\"api.example.com\"]"));
804        assert!(out.contains("# seen in: app.py:4"));
805        assert!(out.contains("[target.\"llm:openai\"]"));
806        assert!(out.contains("# seen in: app.py:2"));
807        assert!(out.contains("cache   = { mode = \"dev\" }"));
808    }
809
810    #[test]
811    fn discovery_only_target_is_surfaced_with_observed_comment() {
812        let scan = ScanResult {
813            files_scanned: 1,
814            python_available: true,
815            ..ScanResult::default()
816        };
817        let stats = TargetStats {
818            target: "api.dynamic.com".to_owned(),
819            calls: 120,
820            attempts: 132,
821            retries: 12,
822            successes: 120,
823            failures: 0,
824            cache_hits: 0,
825            throttled: 0,
826            breaker_opens: 0,
827            total_latency_ms: 12_000,
828            max_latency_ms: 300,
829            first_seen_ms: 0,
830            last_seen_ms: 120_000, // 2 minutes → 60/min
831            last_error_class: None,
832            last_error_status: None,
833            not_retried: 0,
834            unwrapped_calls: 0,
835        };
836        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
837        assert!(out.contains("[target.\"api.dynamic.com\"]"));
838        assert!(out.contains("# seen only at runtime (.keel/discovery.db)"));
839        assert!(out.contains("# observed: 120 calls, 12 retries, ~60.0/min mean"));
840        // header now reports one observed run
841        assert!(out.contains("+ 1 observed run\n"));
842    }
843
844    #[test]
845    fn error_class_import_is_available() {
846        // Guards the keel_journal re-export used by status/doctor tests too.
847        let _ = ErrorClass::Http;
848    }
849
850    #[test]
851    fn default_body_matches_the_frozen_pack() {
852        // The hardcoded policy bodies must equal contracts/defaults.toml.
853        let defaults: toml::Value = include_str!("../contract/defaults.toml")
854            .parse()
855            .expect("defaults.toml parses");
856        let outbound = &defaults["defaults"]["outbound"];
857        assert_eq!(outbound["timeout"].as_str(), Some("30s"));
858        assert_eq!(outbound["retry"]["attempts"].as_integer(), Some(3));
859        assert_eq!(outbound["breaker"]["cooldown"].as_str(), Some("15s"));
860        let llm = &defaults["defaults"]["llm"];
861        assert_eq!(llm["timeout"].as_str(), Some("120s"));
862        assert_eq!(llm["retry"]["attempts"].as_integer(), Some(6));
863        assert_eq!(llm["breaker"]["cooldown"].as_str(), Some("30s"));
864        assert_eq!(llm["cache"]["mode"].as_str(), Some("dev"));
865        // and the bodies we emit reflect those values
866        assert!(policy_body(TargetClass::Host).contains("attempts = 3"));
867        assert!(policy_body(TargetClass::Host).contains("cooldown = \"15s\""));
868        assert!(policy_body(TargetClass::Llm).contains("attempts = 6"));
869        assert!(policy_body(TargetClass::Llm).contains("cooldown = \"30s\""));
870        assert!(policy_body(TargetClass::Llm).contains("mode = \"dev\""));
871    }
872
873    #[test]
874    fn civil_date_epoch_is_1970_01_01() {
875        assert_eq!(civil_from_days(0), (1970, 1, 1));
876        assert_eq!(civil_from_days(19_997), (2024, 10, 1));
877    }
878
879    // ---- item 3: evidence-tuned llm rate derivation ----
880
881    #[test]
882    fn round_up_clean_walks_the_1_2_5_series() {
883        assert_eq!(round_up_clean(0), 0);
884        assert_eq!(round_up_clean(1), 1);
885        assert_eq!(round_up_clean(3), 5);
886        assert_eq!(round_up_clean(6), 10);
887        assert_eq!(round_up_clean(11), 20);
888        assert_eq!(round_up_clean(50), 50);
889        assert_eq!(round_up_clean(60), 100);
890        assert_eq!(round_up_clean(123), 200);
891        assert_eq!(round_up_clean(300), 500);
892        assert_eq!(round_up_clean(501), 1_000);
893    }
894
895    #[test]
896    fn llm_rate_is_mean_times_three_rounded_up_to_a_clean_value() {
897        // 200 calls over a 2-min window → mean 100/min → ×3 = 300 → clean 500.
898        let s = llm_stats(200, 0, 120_000);
899        assert_eq!(mean_per_min_floor(&s), 100);
900        assert_eq!(llm_rate_per_min(&s), 500);
901    }
902
903    #[test]
904    fn llm_rate_floors_at_60_for_sparse_traffic() {
905        // 5 calls over 1 min → mean 5/min → ×3 = 15 → clean 20 → floored to 60.
906        let s = llm_stats(5, 0, 60_000);
907        assert_eq!(mean_per_min_floor(&s), 5);
908        assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
909    }
910
911    #[test]
912    fn llm_rate_zero_span_window_falls_back_to_floor() {
913        // Single-instant window (first_seen == last_seen): no mean derivable.
914        let s = llm_stats(500, 1_000, 1_000);
915        assert_eq!(mean_per_min_floor(&s), 0);
916        assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
917    }
918
919    #[test]
920    fn observed_llm_target_gets_an_active_rate_line() {
921        let scan = ScanResult {
922            files_scanned: 1,
923            python_available: true,
924            ..ScanResult::default()
925        };
926        let stats = llm_stats(200, 0, 120_000);
927        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
928
929        assert!(out.contains("[target.\"llm:openai\"]"));
930        assert!(out.contains("rate    = \"500/min\""));
931        assert!(out.contains("# headroom over your observed mean of ~100/min"));
932        // We measure a mean, never a peak — the word must never appear.
933        assert!(!out.contains("peak"));
934        // The rate line sits between breaker and cache.
935        let rate_at = out.find("rate    =").expect("rate line present");
936        let cache_at = out.find("cache   =").expect("cache line present");
937        assert!(rate_at < cache_at, "rate must precede cache");
938    }
939
940    #[test]
941    fn zero_span_llm_target_emits_floor_with_honest_comment() {
942        let scan = ScanResult {
943            files_scanned: 1,
944            python_available: true,
945            ..ScanResult::default()
946        };
947        let stats = llm_stats(9, 5_000, 5_000);
948        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
949        assert!(out.contains("rate    = \"60/min\""));
950        assert!(out.contains("# floor: single observation window, no mean to derive"));
951        assert!(!out.contains("peak"));
952    }
953
954    #[test]
955    fn observed_host_target_stays_comments_only() {
956        // Host targets never get an active rate, even with observed traffic.
957        let scan = ScanResult {
958            files_scanned: 1,
959            python_available: true,
960            ..ScanResult::default()
961        };
962        let stats = TargetStats {
963            target: "api.host.example".to_owned(),
964            ..llm_stats(200, 0, 120_000)
965        };
966        let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
967        assert!(out.contains("[target.\"api.host.example\"]"));
968        assert!(
969            !out.contains("rate    ="),
970            "host targets must not emit an active rate line"
971        );
972    }
973
974    // ---- item 2: keel init write path ----
975
976    #[test]
977    fn refuses_when_keel_toml_already_exists() {
978        let dir = TempDir::new().unwrap();
979        fs::write(dir.path().join("keel.toml"), "# hand-written\n").unwrap();
980
981        let r = run(dir.path(), InitOptions::default());
982
983        assert_eq!(r.exit, EXIT_USAGE);
984        assert!(r.to_stderr);
985        assert!(r.human.contains("already exists"));
986        // The existing file is left untouched.
987        assert_eq!(
988            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
989            "# hand-written\n"
990        );
991    }
992
993    #[test]
994    fn diff_reports_added_and_removed_targets_precisely() {
995        let dir = TempDir::new().unwrap();
996        // JS scan (pure Rust, no python3) will find `api.example.com`.
997        fs::write(
998            dir.path().join("app.mjs"),
999            "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1000        )
1001        .unwrap();
1002        // An existing keel.toml declares a target the scan will NOT find.
1003        fs::write(
1004            dir.path().join("keel.toml"),
1005            "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n",
1006        )
1007        .unwrap();
1008
1009        let r = run(
1010            dir.path(),
1011            InitOptions {
1012                diff: true,
1013                stamp: false,
1014                agents: false,
1015            },
1016        );
1017
1018        assert_eq!(r.exit, crate::EXIT_OK);
1019        assert_eq!(
1020            r.json["added"].as_array().unwrap(),
1021            &vec![serde_json::json!("api.example.com")]
1022        );
1023        assert_eq!(
1024            r.json["removed"].as_array().unwrap(),
1025            &vec![serde_json::json!("api.gone.example")]
1026        );
1027        assert!(r.json["unchanged"].as_array().unwrap().is_empty());
1028        assert!(r.human.contains("+ [target.\"api.example.com\"]"));
1029        assert!(r.human.contains("- [target.\"api.gone.example\"]"));
1030        // --diff never writes.
1031        assert_eq!(
1032            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1033            "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n"
1034        );
1035    }
1036
1037    /// dx-spec §5 (diffs as the lingua franca): `--diff` emits an applyable
1038    /// patch. Applying it removes stale blocks and appends evidence-cited new
1039    /// ones while user tuning outside the touched blocks survives byte-for-byte.
1040    #[test]
1041    fn diff_emits_an_applyable_patch_and_structured_changes() {
1042        let dir = TempDir::new().unwrap();
1043        fs::write(
1044            dir.path().join("app.mjs"),
1045            "// 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",
1046        )
1047        .unwrap();
1048        let old = "\
1049# hand-tuned: keep this comment
1050
1051[target.\"api.example.com\"]
1052timeout = \"9s\"   # user tuning survives
1053
1054[target.\"api.gone.example\"]  # stale
1055timeout = \"5s\"
1056";
1057        fs::write(dir.path().join("keel.toml"), old).unwrap();
1058
1059        let r = run(
1060            dir.path(),
1061            InitOptions {
1062                diff: true,
1063                stamp: false,
1064                agents: false,
1065            },
1066        );
1067
1068        assert_eq!(r.exit, crate::EXIT_OK);
1069        let patch = r.json["patch"].as_str().unwrap();
1070        assert!(
1071            patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"),
1072            "{patch}"
1073        );
1074        assert!(r.human.contains("apply with `git apply`"));
1075        assert!(
1076            r.human.contains(patch),
1077            "the human output carries the patch verbatim"
1078        );
1079
1080        let applied = crate::diff::apply_unified(old, patch).unwrap();
1081        let value: toml::Value = applied.parse().expect("applied file parses");
1082        assert!(value["target"].get("api.gone.example").is_none());
1083        assert!(value["target"].get("api.new.example").is_some());
1084        assert!(applied.contains("# hand-tuned: keep this comment"));
1085        assert!(applied.contains("timeout = \"9s\"   # user tuning survives"));
1086        // The added block is byte-identical to what a fresh init would write.
1087        assert!(applied.contains("[target.\"api.new.example\"]"));
1088        assert!(applied.contains("# seen in: app.mjs:3"));
1089
1090        // Structured hunks: one removal, one addition, sorted by path.
1091        let changes = r.json["changes"].as_array().unwrap();
1092        let paths: Vec<&str> = changes
1093            .iter()
1094            .map(|c| c["path"].as_str().unwrap())
1095            .collect();
1096        assert_eq!(
1097            paths,
1098            ["target.\"api.gone.example\"", "target.\"api.new.example\""]
1099        );
1100        assert!(changes[0]["after"].is_null());
1101        assert!(changes[1]["before"].is_null());
1102        // --diff never writes.
1103        assert_eq!(
1104            fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1105            old
1106        );
1107    }
1108
1109    /// With no keel.toml the patch creates the whole generated file from
1110    /// `/dev/null`, byte-identical to what `keel init` would write.
1111    #[test]
1112    fn diff_without_existing_file_is_a_dev_null_creation_patch() {
1113        let dir = TempDir::new().unwrap();
1114        fs::write(
1115            dir.path().join("app.mjs"),
1116            "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1117        )
1118        .unwrap();
1119
1120        let r = run(
1121            dir.path(),
1122            InitOptions {
1123                diff: true,
1124                stamp: false,
1125                agents: false,
1126            },
1127        );
1128
1129        assert_eq!(r.exit, crate::EXIT_OK);
1130        let patch = r.json["patch"].as_str().unwrap();
1131        assert!(
1132            patch.starts_with("--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,"),
1133            "{patch}"
1134        );
1135        let scanned = scan::scan(dir.path());
1136        let expected = render_keel_toml(&scanned, &[], None);
1137        assert_eq!(crate::diff::apply_unified("", patch).unwrap(), expected);
1138        assert_eq!(
1139            r.json["added"].as_array().unwrap(),
1140            &vec![serde_json::json!("api.example.com")]
1141        );
1142    }
1143
1144    // ---- keel init --agents ----
1145
1146    #[test]
1147    fn agents_creates_then_is_idempotent() {
1148        let dir = TempDir::new().unwrap();
1149        let opts = InitOptions {
1150            agents: true,
1151            ..InitOptions::default()
1152        };
1153        let r1 = run(dir.path(), opts);
1154        assert_eq!(r1.exit, crate::EXIT_OK);
1155        assert!(r1.json["wrote"].as_bool().unwrap());
1156        let path = dir.path().join("AGENTS.md");
1157        let c1 = fs::read_to_string(&path).unwrap();
1158        assert!(c1.contains("## Keel (resilience & durable execution)"));
1159        assert!(c1.contains("keel doctor --json"));
1160
1161        // Re-run: nothing to change → already current, file byte-identical.
1162        let r2 = run(dir.path(), opts);
1163        assert!(r2.json["already_current"].as_bool().unwrap());
1164        assert_eq!(fs::read_to_string(&path).unwrap(), c1);
1165    }
1166
1167    #[test]
1168    fn splice_appends_then_replaces_region_without_duplicating() {
1169        let block = agents_block();
1170        // Append below existing prose.
1171        let (out, replaced, wrote) = splice_agents_block("# My project\n", &block);
1172        assert!(!replaced && wrote);
1173        assert!(out.starts_with("# My project\n\n"));
1174        assert!(out.contains(AGENTS_BEGIN) && out.contains(AGENTS_END));
1175        // Re-splicing the same block replaces in place and is a no-op write.
1176        let (out2, replaced2, wrote2) = splice_agents_block(&out, &block);
1177        assert!(replaced2 && !wrote2);
1178        assert_eq!(out2, out);
1179        assert_eq!(
1180            out2.matches(AGENTS_BEGIN).count(),
1181            1,
1182            "exactly one Keel block"
1183        );
1184    }
1185
1186    #[test]
1187    fn gitignore_is_created_when_absent() {
1188        let dir = TempDir::new().unwrap();
1189        assert!(update_gitignore(dir.path()).unwrap());
1190        assert_eq!(
1191            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1192            ".keel/\n"
1193        );
1194    }
1195
1196    #[test]
1197    fn gitignore_is_appended_when_keel_line_missing() {
1198        let dir = TempDir::new().unwrap();
1199        // No trailing newline: the appender must add one before `.keel/`.
1200        fs::write(dir.path().join(".gitignore"), "node_modules/\n*.log").unwrap();
1201
1202        assert!(update_gitignore(dir.path()).unwrap());
1203
1204        assert_eq!(
1205            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1206            "node_modules/\n*.log\n.keel/\n"
1207        );
1208    }
1209
1210    #[test]
1211    fn gitignore_is_a_noop_when_already_ignored() {
1212        let dir = TempDir::new().unwrap();
1213        let original = "build/\n.keel/\ncoverage/\n";
1214        fs::write(dir.path().join(".gitignore"), original).unwrap();
1215
1216        assert!(!update_gitignore(dir.path()).unwrap());
1217
1218        assert_eq!(
1219            fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1220            original
1221        );
1222    }
1223}