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