1use 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
25const COMMENT_COL: usize = 37;
27
28#[derive(Debug, Clone, Copy, Default)]
30pub struct InitOptions {
31 pub diff: bool,
33 pub stamp: bool,
35 pub agents: bool,
38}
39
40const AGENTS_BEGIN: &str = "<!-- keel:begin -->";
43const AGENTS_END: &str = "<!-- keel:end -->";
44
45const 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#[must_use]
73pub fn agents_block() -> String {
74 format!("{AGENTS_BEGIN}\n{AGENTS_SNIPPET}\n{AGENTS_END}\n")
75}
76
77#[derive(Debug, Serialize)]
79struct AgentsReport {
80 already_current: bool,
81 path: String,
82 updated: bool,
83 wrote: bool,
84}
85
86fn 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 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
122fn 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 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#[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#[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
172pub 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
235fn 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
245pub 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
280fn 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 (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 _ => out.push_str(&policy_body(class)),
335 }
336 buf
337}
338
339const 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
347const 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
356const LLM_CACHE_LINE: &str =
358 "cache = { mode = \"dev\" } # dev-loop cache; disabled when KEEL_ENV=prod\n";
359
360const LLM_RATE_FLOOR_PER_MIN: u64 = 60;
365
366const LLM_RATE_HEADROOM: u64 = 3;
371
372fn 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
384fn 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
397fn 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
409fn 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
431fn 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
447fn 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
459fn 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
477fn 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
488fn 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 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
583fn 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
594fn 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
608fn 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
633pub(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
661fn 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
681pub(crate) fn plural(n: usize) -> &'static str {
683 if n == 1 { "" } else { "s" }
684}
685
686fn 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
698fn 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 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 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, 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 assert!(out.contains("+ 1 observed run\n"));
842 }
843
844 #[test]
845 fn error_class_import_is_available() {
846 let _ = ErrorClass::Http;
848 }
849
850 #[test]
851 fn default_body_matches_the_frozen_pack() {
852 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 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 #[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 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 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 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 assert!(!out.contains("peak"));
934 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 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 #[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 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 fs::write(
998 dir.path().join("app.mjs"),
999 "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1000 )
1001 .unwrap();
1002 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 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 #[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 assert!(applied.contains("[target.\"api.new.example\"]"));
1088 assert!(applied.contains("# seen in: app.mjs:3"));
1089
1090 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 assert_eq!(
1104 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1105 old
1106 );
1107 }
1108
1109 #[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 #[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 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 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 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 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}