1use std::collections::{BTreeMap, BTreeSet};
15use std::path::Path;
16
17use keel_journal::TargetStats;
18use serde::Serialize;
19
20use crate::agents_cli;
21use crate::diff::{ChangeHunk, PolicyOp, PolicyPath, propose};
22use crate::render::to_json;
23use crate::scan::{ScanResult, TargetClass, TargetEvidence};
24use crate::{EXIT_USAGE, Rendered, evidence, scan};
25
26const COMMENT_COL: usize = 37;
28
29#[derive(Debug, Clone, Copy, Default)]
31pub struct InitOptions {
32 pub diff: bool,
34 pub stamp: bool,
36 pub agents: bool,
39}
40
41const AGENTS_BEGIN: &str = "<!-- keel:begin -->";
44const AGENTS_END: &str = "<!-- keel:end -->";
45
46const AGENTS_SNIPPET: &str = include_str!("../templates/agents-snippet.md");
53
54#[must_use]
57pub fn agents_block() -> String {
58 format!("{AGENTS_BEGIN}\n{AGENTS_SNIPPET}\n{AGENTS_END}\n")
59}
60
61#[derive(Debug, Serialize)]
63struct AgentsReport {
64 already_current: bool,
65 path: String,
66 updated: bool,
67 wrote: bool,
68}
69
70fn run_agents(project: &Path) -> Rendered {
74 let path = project.join("AGENTS.md");
75 let existing = match std::fs::read_to_string(&path) {
76 Ok(text) => text,
77 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
78 Err(e) => return config_error(&format!("could not read {}: {e}", path.display())),
79 };
80 let block = agents_block();
81 let (new_content, replaced, wrote) = splice_agents_block(&existing, &block);
82 let already_current = !wrote;
83 let updated = wrote && replaced;
86 if wrote && let Err(e) = std::fs::write(&path, &new_content) {
87 return config_error(&format!("could not write {}: {e}", path.display()));
88 }
89 let verb = if already_current {
90 "already current"
91 } else if updated {
92 "updated the Keel section in"
93 } else {
94 "wrote the Keel section to"
95 };
96 let human = format!("keel \u{25b8} {verb} {}", path.display());
97 let report = AgentsReport {
98 already_current,
99 path: path.display().to_string(),
100 updated,
101 wrote,
102 };
103 Rendered::ok(human, to_json(&report))
104}
105
106fn splice_agents_block(existing: &str, block: &str) -> (String, bool, bool) {
110 if let (Some(start), Some(end_idx)) = (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
111 let end = end_idx + AGENTS_END.len();
112 let tail_start = existing[end..].strip_prefix('\n').map_or(end, |_| end + 1);
115 let mut out = String::with_capacity(existing.len());
116 out.push_str(&existing[..start]);
117 out.push_str(block);
118 out.push_str(&existing[tail_start..]);
119 let needs_write = out != existing;
120 return (out, true, needs_write);
121 }
122 if existing.is_empty() {
123 return (block.to_owned(), false, true);
124 }
125 let mut out = existing.to_owned();
126 if !out.ends_with('\n') {
127 out.push('\n');
128 }
129 out.push('\n');
130 out.push_str(block);
131 (out, false, true)
132}
133
134#[derive(Debug, Serialize)]
136struct WroteReport {
137 gitignore_updated: bool,
138 observed_runs: u32,
139 static_scans: usize,
140 targets: Vec<String>,
141 wrote: String,
142}
143
144#[derive(Debug, Serialize)]
148struct DiffReport {
149 added: Vec<String>,
150 changes: Vec<ChangeHunk>,
151 notes: Vec<String>,
152 patch: String,
153 removed: Vec<String>,
154 unchanged: Vec<String>,
155}
156
157pub fn run(project: &Path, opts: InitOptions) -> Rendered {
159 if opts.agents {
160 return run_agents(project);
161 }
162 let scan = scan::scan(project);
163 let discovery = match evidence::read_discovery(project) {
164 Ok(d) => d,
165 Err(e) => return config_error(&e),
166 };
167
168 let stamp = opts.stamp.then(today_utc);
169 let content = render_keel_toml(&scan, &discovery, stamp.as_deref());
170 let targets = merged_targets(&scan, &discovery);
171
172 let agents_cli_path = agents_cli_toml_path(project);
173 let toml_path = agents_cli_path
174 .clone()
175 .unwrap_or_else(|| evidence::keel_toml(project));
176 if opts.diff {
177 return diff(&toml_path, &scan, &discovery, &targets, stamp.as_deref());
178 }
179 if toml_path.exists() {
180 return config_error(&format!(
181 "{} already exists. Run `keel init --diff` to preview changes, or edit it directly.",
182 toml_path.display()
183 ));
184 }
185
186 if let Err(e) = std::fs::write(&toml_path, &content) {
187 return config_error(&format!("could not write {}: {e}", toml_path.display()));
188 }
189 if agents_cli_path.is_some() {
194 eprintln!(
195 "keel \u{25b8} agents-cli project detected \u{2014} writing {} so it ships in the \
196 container image",
197 relative_display(project, &toml_path)
198 );
199 }
200 let gitignore_updated = update_gitignore(project).unwrap_or(false);
201
202 let mut warnings = String::new();
203 if !scan.python_available && has_python_files(project) {
204 warnings.push_str(
205 "\nkeel \u{25b8} note: python3 was not found; Python files were not scanned.\n",
206 );
207 }
208
209 let observed_runs = u32::from(!discovery.is_empty());
210 let human = format!(
211 "keel \u{25b8} wrote {} ({} target{}) from {} static scan{} + {} observed run{}.{}",
212 toml_path.display(),
213 targets.len(),
214 plural(targets.len()),
215 scan.files_scanned,
216 plural(scan.files_scanned),
217 observed_runs,
218 plural(observed_runs as usize),
219 if warnings.is_empty() {
220 String::new()
221 } else {
222 warnings
223 }
224 );
225 let report = WroteReport {
226 gitignore_updated,
227 observed_runs,
228 static_scans: scan.files_scanned,
229 targets,
230 wrote: toml_path.display().to_string(),
231 };
232 Rendered::ok(human, to_json(&report))
233}
234
235fn agents_cli_toml_path(project: &Path) -> Option<std::path::PathBuf> {
259 let layout = agents_cli::find_agents_cli_layout(project)?;
260 let canonical_project = std::fs::canonicalize(project).ok()?;
261 let canonical_agent_dir = std::fs::canonicalize(&layout.agent_dir).ok()?;
262 if !canonical_agent_dir.starts_with(&canonical_project) {
263 return None;
264 }
265 Some(layout.agent_dir.join("keel.toml"))
266}
267
268fn relative_display(base: &Path, target: &Path) -> String {
273 target.strip_prefix(base).map_or_else(
274 |_| target.display().to_string(),
275 |rel| rel.display().to_string(),
276 )
277}
278
279fn merged_targets(scan: &ScanResult, discovery: &[TargetStats]) -> Vec<String> {
282 let mut set: BTreeSet<String> = scan.targets.keys().cloned().collect();
283 for stats in discovery {
284 set.insert(stats.target.clone());
285 }
286 set.into_iter().collect()
287}
288
289pub fn render_keel_toml(
292 scan: &ScanResult,
293 discovery: &[TargetStats],
294 stamp: Option<&str>,
295) -> String {
296 render_keel_toml_for_targets(scan, discovery, &merged_targets(scan, discovery), stamp)
297}
298
299fn render_keel_toml_for_targets(
306 scan: &ScanResult,
307 discovery: &[TargetStats],
308 targets: &[String],
309 stamp: Option<&str>,
310) -> String {
311 let by_target: BTreeMap<&str, &TargetStats> =
312 discovery.iter().map(|s| (s.target.as_str(), s)).collect();
313 let observed_runs = u32::from(!discovery.is_empty());
314
315 let mut out = String::new();
316 let date = stamp.map_or_else(String::new, |d| format!(" ({d})"));
317 let header = format!(
318 "# Generated by keel init from {} static scan{} + {} observed run{}{}\n",
319 scan.files_scanned,
320 plural(scan.files_scanned),
321 observed_runs,
322 plural(observed_runs as usize),
323 date,
324 );
325 out.push_str(&header);
326 out.push_str(
327 "# Every entry below was found in YOUR code. Delete anything; defaults still apply.\n",
328 );
329
330 for target in targets {
331 out.push('\n');
332 let evidence = scan.targets.get(target);
333 let stats = by_target.get(target.as_str()).copied();
334 out.push_str(&render_target_block(target, evidence, stats));
335 }
336 out
337}
338
339fn render_target_block(
344 target: &str,
345 evidence: Option<&TargetEvidence>,
346 stats: Option<&TargetStats>,
347) -> String {
348 let mut buf = String::new();
349 let out = &mut buf;
350 let header = format!("[target.\"{target}\"]");
351 let seen_comment = evidence.map(|e| {
352 let labels = e
353 .sightings
354 .iter()
355 .map(scan::Sighting::label)
356 .collect::<Vec<_>>()
357 .join(", ");
358 format!("# seen in: {labels}")
359 });
360 let comment =
361 seen_comment.unwrap_or_else(|| "# seen only at runtime (.keel/discovery.db)".to_owned());
362 out.push_str(&pad_comment(&header, &comment));
363 out.push('\n');
364
365 if let Some(s) = stats {
366 let observed = format!("# {}\n", observed_comment(s));
367 out.push_str(&observed);
368 }
369
370 let class = evidence.map_or_else(
371 || {
372 if target.starts_with("llm:") {
373 TargetClass::Llm
374 } else {
375 TargetClass::Host
376 }
377 },
378 |e| e.class,
379 );
380 match (class, stats) {
381 (TargetClass::Llm, Some(s)) => {
384 out.push_str(LLM_BODY_HEAD);
385 out.push_str(&observed_rate_line(s));
386 out.push('\n');
387 out.push_str(LLM_CACHE_LINE);
388 }
389 _ => out.push_str(&policy_body(class)),
394 }
395 buf
396}
397
398const HOST_BODY: &str = concat!(
401 "timeout = \"30s\"\n",
402 "retry = { attempts = 3, schedule = \"exp(200ms, x2, max 30s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
403 "breaker = { failures = 5, cooldown = \"15s\" }\n",
404);
405
406const LLM_BODY_HEAD: &str = concat!(
410 "timeout = \"120s\"\n",
411 "retry = { attempts = 6, schedule = \"exp(500ms, x2, max 60s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
412 "breaker = { failures = 5, cooldown = \"30s\" }\n",
413);
414
415const LLM_CACHE_LINE: &str =
417 "cache = { mode = \"dev\" } # dev-loop cache; disabled when KEEL_ENV=prod\n";
418
419const LLM_RATE_FLOOR_PER_MIN: u64 = 60;
424
425const LLM_RATE_HEADROOM: u64 = 3;
430
431fn policy_body(class: TargetClass) -> String {
437 match class {
438 TargetClass::Host => HOST_BODY.to_owned(),
439 TargetClass::Llm => format!("{LLM_BODY_HEAD}{LLM_CACHE_LINE}"),
440 }
441}
442
443fn mean_per_min_floor(s: &TargetStats) -> u64 {
448 let span_ms = u64::try_from((s.last_seen_ms - s.first_seen_ms).max(0)).unwrap_or(u64::MAX);
449 if span_ms == 0 {
450 return 0;
451 }
452 let calls = u64::try_from(s.calls.max(0)).unwrap_or(u64::MAX);
453 calls.saturating_mul(60_000) / span_ms
454}
455
456fn llm_rate_per_min(s: &TargetStats) -> u64 {
461 let mean = mean_per_min_floor(s);
462 if mean == 0 {
463 return LLM_RATE_FLOOR_PER_MIN;
464 }
465 round_up_clean(mean.saturating_mul(LLM_RATE_HEADROOM)).max(LLM_RATE_FLOOR_PER_MIN)
466}
467
468fn round_up_clean(n: u64) -> u64 {
472 if n == 0 {
473 return 0;
474 }
475 let mut unit = 1_u64;
476 loop {
477 for m in [1_u64, 2, 5] {
478 let candidate = m.saturating_mul(unit);
479 if candidate >= n {
480 return candidate;
481 }
482 }
483 match unit.checked_mul(10) {
484 Some(next) => unit = next,
485 None => return u64::MAX,
486 }
487 }
488}
489
490fn observed_rate_line(s: &TargetStats) -> String {
494 let mean = mean_per_min_floor(s);
495 let comment = if mean == 0 {
496 "# floor: single observation window, no mean to derive".to_owned()
497 } else {
498 format!("# headroom over your observed mean of ~{mean}/min")
499 };
500 pad_comment(
501 &format!("rate = \"{}/min\"", llm_rate_per_min(s)),
502 &comment,
503 )
504}
505
506fn observed_comment(s: &TargetStats) -> String {
508 format!(
509 "observed: {} call{}, {} retr{}, ~{:.1}/min mean (.keel/discovery.db)",
510 s.calls,
511 plural(usize::try_from(s.calls).unwrap_or(usize::MAX)),
512 s.retries,
513 if s.retries == 1 { "y" } else { "ies" },
514 per_minute(s),
515 )
516}
517
518fn per_minute(s: &TargetStats) -> f64 {
521 #[expect(
522 clippy::cast_precision_loss,
523 reason = "call counts and ms spans are small; f64 is exact enough for a comment"
524 )]
525 let (calls, span_ms) = (
526 s.calls as f64,
527 (s.last_seen_ms - s.first_seen_ms).max(0) as f64,
528 );
529 if span_ms <= 0.0 {
530 calls
531 } else {
532 calls * 60_000.0 / span_ms
533 }
534}
535
536fn pad_comment(line: &str, comment: &str) -> String {
539 let width = if line.len() < COMMENT_COL {
540 COMMENT_COL
541 } else {
542 line.len() + 1
543 };
544 format!("{line:<width$}{comment}")
545}
546
547fn diff_notes(scan: &ScanResult, added: &[String]) -> Vec<String> {
555 let mut notes = Vec::new();
556 let added_set: BTreeSet<&str> = added.iter().map(String::as_str).collect();
557 for s in &scan.simplifications {
558 if s.targets.iter().any(|t| added_set.contains(t.as_str())) {
559 notes.push(format!(
560 "once wrapped: {} in `{}` at {}:{} becomes redundant (target {})",
561 s.kind,
562 s.function,
563 s.file,
564 s.line,
565 s.targets.join(", ")
566 ));
567 }
568 }
569 let registry_libs = crate::doctor::registry_libs();
578 if !scan.resilience_libs.is_empty()
579 && scan.libs.iter().any(|l| registry_libs.contains(l.as_str()))
580 {
581 let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
582 notes.push(format!(
583 "pre-existing resilience: this project imports {} — once Keel wraps the same calls, \
584 delete the old retry/backoff or scope Keel's policy (see `keel doctor`)",
585 libs.join(", ")
586 ));
587 }
588 notes
589}
590
591fn render_diff_trailer(excluded: &[crate::doctor::TopologyEntry], notes: &[String]) -> String {
595 let mut out = String::new();
596 if !excluded.is_empty() {
597 let mut excluded: Vec<_> = excluded.iter().collect();
602 excluded.sort_by(|a, b| a.host.cmp(&b.host));
603 out.push('\n');
604 for entry in excluded {
605 let line = format!(
606 "# excluded (dependency-averse): {} — {}\n",
607 entry.host, entry.reason
608 );
609 out.push_str(&line);
610 }
611 }
612 if !notes.is_empty() {
613 out.push('\n');
614 for note in notes {
615 let line = format!("# note: {note}\n");
616 out.push_str(&line);
617 }
618 }
619 out
620}
621
622fn diff(
638 toml_path: &Path,
639 scan: &ScanResult,
640 discovery: &[TargetStats],
641 generated: &[String],
642 stamp: Option<&str>,
643) -> Rendered {
644 let existing_text = match read_existing(toml_path) {
645 Ok(t) => t,
646 Err(e) => return config_error(&e),
647 };
648 let existing = match existing_text
649 .as_deref()
650 .map(|text| existing_targets(text, toml_path))
651 .transpose()
652 {
653 Ok(set) => set.unwrap_or_default(),
654 Err(e) => return config_error(&e),
655 };
656
657 let wrapped_targets: BTreeSet<String> = discovery.iter().map(|s| s.target.clone()).collect();
658 let topology = crate::doctor::classify_topology(scan, &wrapped_targets, &BTreeMap::new());
663 let excluded_hosts: BTreeSet<&str> =
664 topology.excluded.iter().map(|e| e.host.as_str()).collect();
665
666 let generated_set: BTreeSet<&str> = generated.iter().map(String::as_str).collect();
667 let added: Vec<String> = generated_set
668 .iter()
669 .filter(|t| !existing.contains(**t) && !excluded_hosts.contains(**t))
670 .map(|t| (*t).to_owned())
671 .collect();
672 let removed: Vec<String> = existing
673 .iter()
674 .filter(|t| !generated_set.contains(t.as_str()))
675 .cloned()
676 .collect();
677 let unchanged: Vec<String> = generated_set
678 .iter()
679 .filter(|t| existing.contains(**t))
680 .map(|t| (*t).to_owned())
681 .collect();
682 let notes = diff_notes(scan, &added);
683
684 let ops = if existing_text.is_none() {
685 vec![PolicyOp::AppendBlock {
689 text: render_keel_toml_for_targets(scan, discovery, &added, stamp),
690 }]
691 } else {
692 let by_target: BTreeMap<&str, &TargetStats> =
693 discovery.iter().map(|s| (s.target.as_str(), s)).collect();
694 let mut ops: Vec<PolicyOp> = removed
695 .iter()
696 .map(|t| PolicyOp::Remove {
697 path: PolicyPath::new(["target", t.as_str()]),
698 })
699 .collect();
700 ops.extend(added.iter().map(|t| PolicyOp::AppendBlock {
701 text: render_target_block(t, scan.targets.get(t), by_target.get(t.as_str()).copied()),
702 }));
703 ops
704 };
705 let proposal = match propose(existing_text.as_deref(), &ops) {
706 Ok(p) => p,
707 Err(e) => return config_error(&e.to_string()),
708 };
709
710 let mut human = String::from("keel \u{25b8} keel init --diff\n");
711 if added.is_empty() && removed.is_empty() {
712 if topology.excluded.is_empty() {
713 human.push_str(" no changes: every discovered target is already in keel.toml.\n");
714 } else {
715 human.push_str(
716 " no policy changes: every discovered target is already in keel.toml or excluded below.\n",
717 );
718 }
719 } else {
720 for t in &added {
721 let line = format!(" + [target.\"{t}\"] (found in code, not in keel.toml)\n");
722 human.push_str(&line);
723 }
724 for t in &removed {
725 let line = format!(" - [target.\"{t}\"] (in keel.toml, no longer found in code)\n");
726 human.push_str(&line);
727 }
728 }
729 if !proposal.patch.is_empty() {
730 human.push_str("\napply with `git apply` (or `patch -p1`):\n\n");
731 human.push_str(&proposal.patch);
732 }
733 human.push_str(&render_diff_trailer(&topology.excluded, ¬es));
734 let report = DiffReport {
735 added,
736 changes: proposal.changes,
737 notes,
738 patch: proposal.patch,
739 removed,
740 unchanged,
741 };
742 Rendered::ok(human, to_json(&report))
743}
744
745fn read_existing(toml_path: &Path) -> Result<Option<String>, String> {
748 if !toml_path.exists() {
749 return Ok(None);
750 }
751 std::fs::read_to_string(toml_path)
752 .map(Some)
753 .map_err(|e| format!("could not read {}: {e}", toml_path.display()))
754}
755
756fn existing_targets(text: &str, toml_path: &Path) -> Result<BTreeSet<String>, String> {
758 let value: toml::Value = text
759 .parse()
760 .map_err(|e| format!("{} is not valid TOML: {e}", toml_path.display()))?;
761 let mut set = BTreeSet::new();
762 if let Some(table) = value.get("target").and_then(toml::Value::as_table) {
763 for key in table.keys() {
764 set.insert(key.clone());
765 }
766 }
767 Ok(set)
768}
769
770fn update_gitignore(project: &Path) -> std::io::Result<bool> {
773 let path = project.join(".gitignore");
774 if !path.exists() {
775 std::fs::write(&path, ".keel/\n")?;
776 return Ok(true);
777 }
778 let text = std::fs::read_to_string(&path)?;
779 let ignored = text
780 .lines()
781 .map(str::trim)
782 .any(|l| l == ".keel" || l == ".keel/");
783 if ignored {
784 return Ok(false);
785 }
786 let mut updated = text;
787 if !updated.ends_with('\n') && !updated.is_empty() {
788 updated.push('\n');
789 }
790 updated.push_str(".keel/\n");
791 std::fs::write(&path, updated)?;
792 Ok(true)
793}
794
795pub(crate) fn has_python_files(project: &Path) -> bool {
799 let mut found = Vec::new();
800 scan::collect_files(project, &["py"], &mut found);
801 !found.is_empty()
802}
803
804fn config_error(message: &str) -> Rendered {
806 #[derive(Serialize)]
807 struct ErrReport<'a> {
808 code: &'static str,
809 error: &'a str,
810 }
811 let human = format!("keel \u{25b8} KEEL-E001: {message}");
812 Rendered {
813 human,
814 json: to_json(&ErrReport {
815 code: "KEEL-E001",
816 error: message,
817 }),
818 exit: EXIT_USAGE,
819 to_stderr: true,
820 }
821 .with_exit(EXIT_USAGE)
822}
823
824pub(crate) fn plural(n: usize) -> &'static str {
826 if n == 1 { "" } else { "s" }
827}
828
829fn today_utc() -> String {
833 let secs = std::time::SystemTime::now()
834 .duration_since(std::time::UNIX_EPOCH)
835 .map_or(0, |d| d.as_secs());
836 let days = i64::try_from(secs / 86_400).unwrap_or(0);
837 let (y, m, d) = civil_from_days(days);
838 format!("{y:04}-{m:02}-{d:02}")
839}
840
841fn civil_from_days(z: i64) -> (i64, u32, u32) {
843 let z = z + 719_468;
844 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
845 let doe = z - era * 146_097;
846 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
847 let y = yoe + era * 400;
848 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
849 let mp = (5 * doy + 2) / 153;
850 let d = doy - (153 * mp + 2) / 5 + 1;
851 let m = if mp < 10 { mp + 3 } else { mp - 9 };
852 let y = if m <= 2 { y + 1 } else { y };
853 #[expect(
854 clippy::cast_sign_loss,
855 clippy::cast_possible_truncation,
856 reason = "m,d in 1..=31"
857 )]
858 (y, m as u32, d as u32)
859}
860
861#[cfg(test)]
862mod tests {
863 use std::fs;
864 use std::process::{Command, Stdio};
865
866 use keel_journal::ErrorClass;
867 use tempfile::TempDir;
868
869 use super::*;
870
871 fn python3_present() -> bool {
875 Command::new("python3")
876 .arg("--version")
877 .stdout(Stdio::null())
878 .stderr(Stdio::null())
879 .status()
880 .is_ok_and(|s| s.success())
881 }
882
883 fn llm_stats(calls: i64, first_seen_ms: i64, last_seen_ms: i64) -> TargetStats {
886 TargetStats {
887 target: "llm:openai".to_owned(),
888 calls,
889 attempts: calls,
890 retries: 0,
891 successes: calls,
892 failures: 0,
893 cache_hits: 0,
894 throttled: 0,
895 breaker_opens: 0,
896 total_latency_ms: 0,
897 max_latency_ms: 0,
898 first_seen_ms,
899 last_seen_ms,
900 last_error_class: None,
901 last_error_status: None,
902 not_retried: 0,
903 unwrapped_calls: 0,
904 }
905 }
906
907 fn host_scan() -> ScanResult {
908 let mut s = ScanResult {
909 files_scanned: 2,
910 python_available: true,
911 ..ScanResult::default()
912 };
913 s.targets.insert(
915 "api.example.com".to_owned(),
916 TargetEvidence {
917 class: TargetClass::Host,
918 sightings: [scan::Sighting {
919 file: "app.py".into(),
920 line: 4,
921 }]
922 .into_iter()
923 .collect(),
924 },
925 );
926 s.targets.insert(
927 "llm:openai".to_owned(),
928 TargetEvidence {
929 class: TargetClass::Llm,
930 sightings: [scan::Sighting {
931 file: "app.py".into(),
932 line: 2,
933 }]
934 .into_iter()
935 .collect(),
936 },
937 );
938 s
939 }
940
941 #[test]
942 fn header_counts_and_no_date_by_default() {
943 let out = render_keel_toml(&host_scan(), &[], None);
944 assert!(
945 out.starts_with("# Generated by keel init from 2 static scans + 0 observed runs\n")
946 );
947 assert!(!out.contains("202"), "no date without --stamp");
948 }
949
950 #[test]
951 fn stamp_adds_a_date() {
952 let out = render_keel_toml(&host_scan(), &[], Some("2026-07-12"));
953 assert!(out.lines().next().unwrap().ends_with(" (2026-07-12)"));
954 }
955
956 #[test]
957 fn host_and_llm_blocks_cite_evidence() {
958 let out = render_keel_toml(&host_scan(), &[], None);
959 assert!(out.contains("[target.\"api.example.com\"]"));
960 assert!(out.contains("# seen in: app.py:4"));
961 assert!(out.contains("[target.\"llm:openai\"]"));
962 assert!(out.contains("# seen in: app.py:2"));
963 assert!(out.contains("cache = { mode = \"dev\" }"));
964 }
965
966 #[test]
967 fn discovery_only_target_is_surfaced_with_observed_comment() {
968 let scan = ScanResult {
969 files_scanned: 1,
970 python_available: true,
971 ..ScanResult::default()
972 };
973 let stats = TargetStats {
974 target: "api.dynamic.com".to_owned(),
975 calls: 120,
976 attempts: 132,
977 retries: 12,
978 successes: 120,
979 failures: 0,
980 cache_hits: 0,
981 throttled: 0,
982 breaker_opens: 0,
983 total_latency_ms: 12_000,
984 max_latency_ms: 300,
985 first_seen_ms: 0,
986 last_seen_ms: 120_000, last_error_class: None,
988 last_error_status: None,
989 not_retried: 0,
990 unwrapped_calls: 0,
991 };
992 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
993 assert!(out.contains("[target.\"api.dynamic.com\"]"));
994 assert!(out.contains("# seen only at runtime (.keel/discovery.db)"));
995 assert!(out.contains("# observed: 120 calls, 12 retries, ~60.0/min mean"));
996 assert!(out.contains("+ 1 observed run\n"));
998 }
999
1000 #[test]
1001 fn error_class_import_is_available() {
1002 let _ = ErrorClass::Http;
1004 }
1005
1006 #[test]
1007 fn default_body_matches_the_frozen_pack() {
1008 let defaults: toml::Value = include_str!("../contract/defaults.toml")
1010 .parse()
1011 .expect("defaults.toml parses");
1012 let outbound = &defaults["defaults"]["outbound"];
1013 assert_eq!(outbound["timeout"].as_str(), Some("30s"));
1014 assert_eq!(outbound["retry"]["attempts"].as_integer(), Some(3));
1015 assert_eq!(outbound["breaker"]["cooldown"].as_str(), Some("15s"));
1016 let llm = &defaults["defaults"]["llm"];
1017 assert_eq!(llm["timeout"].as_str(), Some("120s"));
1018 assert_eq!(llm["retry"]["attempts"].as_integer(), Some(6));
1019 assert_eq!(llm["breaker"]["cooldown"].as_str(), Some("30s"));
1020 assert_eq!(llm["cache"]["mode"].as_str(), Some("dev"));
1021 assert!(policy_body(TargetClass::Host).contains("attempts = 3"));
1023 assert!(policy_body(TargetClass::Host).contains("cooldown = \"15s\""));
1024 assert!(policy_body(TargetClass::Llm).contains("attempts = 6"));
1025 assert!(policy_body(TargetClass::Llm).contains("cooldown = \"30s\""));
1026 assert!(policy_body(TargetClass::Llm).contains("mode = \"dev\""));
1027 }
1028
1029 #[test]
1030 fn civil_date_epoch_is_1970_01_01() {
1031 assert_eq!(civil_from_days(0), (1970, 1, 1));
1032 assert_eq!(civil_from_days(19_997), (2024, 10, 1));
1033 }
1034
1035 #[test]
1038 fn round_up_clean_walks_the_1_2_5_series() {
1039 assert_eq!(round_up_clean(0), 0);
1040 assert_eq!(round_up_clean(1), 1);
1041 assert_eq!(round_up_clean(3), 5);
1042 assert_eq!(round_up_clean(6), 10);
1043 assert_eq!(round_up_clean(11), 20);
1044 assert_eq!(round_up_clean(50), 50);
1045 assert_eq!(round_up_clean(60), 100);
1046 assert_eq!(round_up_clean(123), 200);
1047 assert_eq!(round_up_clean(300), 500);
1048 assert_eq!(round_up_clean(501), 1_000);
1049 }
1050
1051 #[test]
1052 fn llm_rate_is_mean_times_three_rounded_up_to_a_clean_value() {
1053 let s = llm_stats(200, 0, 120_000);
1055 assert_eq!(mean_per_min_floor(&s), 100);
1056 assert_eq!(llm_rate_per_min(&s), 500);
1057 }
1058
1059 #[test]
1060 fn llm_rate_floors_at_60_for_sparse_traffic() {
1061 let s = llm_stats(5, 0, 60_000);
1063 assert_eq!(mean_per_min_floor(&s), 5);
1064 assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
1065 }
1066
1067 #[test]
1068 fn llm_rate_zero_span_window_falls_back_to_floor() {
1069 let s = llm_stats(500, 1_000, 1_000);
1071 assert_eq!(mean_per_min_floor(&s), 0);
1072 assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
1073 }
1074
1075 #[test]
1076 fn observed_llm_target_gets_an_active_rate_line() {
1077 let scan = ScanResult {
1078 files_scanned: 1,
1079 python_available: true,
1080 ..ScanResult::default()
1081 };
1082 let stats = llm_stats(200, 0, 120_000);
1083 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1084
1085 assert!(out.contains("[target.\"llm:openai\"]"));
1086 assert!(out.contains("rate = \"500/min\""));
1087 assert!(out.contains("# headroom over your observed mean of ~100/min"));
1088 assert!(!out.contains("peak"));
1090 let rate_at = out.find("rate =").expect("rate line present");
1092 let cache_at = out.find("cache =").expect("cache line present");
1093 assert!(rate_at < cache_at, "rate must precede cache");
1094 }
1095
1096 #[test]
1097 fn zero_span_llm_target_emits_floor_with_honest_comment() {
1098 let scan = ScanResult {
1099 files_scanned: 1,
1100 python_available: true,
1101 ..ScanResult::default()
1102 };
1103 let stats = llm_stats(9, 5_000, 5_000);
1104 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1105 assert!(out.contains("rate = \"60/min\""));
1106 assert!(out.contains("# floor: single observation window, no mean to derive"));
1107 assert!(!out.contains("peak"));
1108 }
1109
1110 #[test]
1111 fn observed_host_target_stays_comments_only() {
1112 let scan = ScanResult {
1114 files_scanned: 1,
1115 python_available: true,
1116 ..ScanResult::default()
1117 };
1118 let stats = TargetStats {
1119 target: "api.host.example".to_owned(),
1120 ..llm_stats(200, 0, 120_000)
1121 };
1122 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1123 assert!(out.contains("[target.\"api.host.example\"]"));
1124 assert!(
1125 !out.contains("rate ="),
1126 "host targets must not emit an active rate line"
1127 );
1128 }
1129
1130 #[test]
1133 fn refuses_when_keel_toml_already_exists() {
1134 let dir = TempDir::new().unwrap();
1135 fs::write(dir.path().join("keel.toml"), "# hand-written\n").unwrap();
1136
1137 let r = run(dir.path(), InitOptions::default());
1138
1139 assert_eq!(r.exit, EXIT_USAGE);
1140 assert!(r.to_stderr);
1141 assert!(r.human.contains("already exists"));
1142 assert_eq!(
1144 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1145 "# hand-written\n"
1146 );
1147 }
1148
1149 #[test]
1155 fn agents_cli_project_writes_keel_toml_into_the_agent_dir() {
1156 let dir = TempDir::new().unwrap();
1157 fs::create_dir(dir.path().join("app")).unwrap();
1158 fs::write(
1159 dir.path().join("agents-cli-manifest.yaml"),
1160 "schema_version: 1\nagent_directory: app\n",
1161 )
1162 .unwrap();
1163
1164 let r = run(dir.path(), InitOptions::default());
1165
1166 assert_eq!(r.exit, crate::EXIT_OK);
1167 assert!(!dir.path().join("keel.toml").exists(), "no root keel.toml");
1168 assert!(
1169 dir.path().join("app").join("keel.toml").exists(),
1170 "keel.toml lands in the agent directory"
1171 );
1172 assert_eq!(
1173 r.json["wrote"].as_str().unwrap(),
1174 dir.path()
1175 .join("app")
1176 .join("keel.toml")
1177 .display()
1178 .to_string()
1179 );
1180 }
1181
1182 #[test]
1186 fn non_agents_cli_project_writes_keel_toml_at_the_root() {
1187 let dir = TempDir::new().unwrap();
1188
1189 let r = run(dir.path(), InitOptions::default());
1190
1191 assert_eq!(r.exit, crate::EXIT_OK);
1192 assert!(dir.path().join("keel.toml").exists());
1193 }
1194
1195 #[test]
1199 fn agents_cli_refuses_when_the_redirected_path_already_exists() {
1200 let dir = TempDir::new().unwrap();
1201 fs::create_dir(dir.path().join("app")).unwrap();
1202 fs::write(
1203 dir.path().join("agents-cli-manifest.yaml"),
1204 "agent_directory: app\n",
1205 )
1206 .unwrap();
1207 fs::write(dir.path().join("app").join("keel.toml"), "# hand-written\n").unwrap();
1208
1209 let r = run(dir.path(), InitOptions::default());
1210
1211 assert_eq!(r.exit, EXIT_USAGE);
1212 assert!(r.human.contains("already exists"));
1213 assert_eq!(
1214 fs::read_to_string(dir.path().join("app").join("keel.toml")).unwrap(),
1215 "# hand-written\n"
1216 );
1217 }
1218
1219 #[test]
1221 fn agents_cli_toml_path_is_none_without_a_manifest() {
1222 let dir = TempDir::new().unwrap();
1223 assert!(agents_cli_toml_path(dir.path()).is_none());
1224 }
1225
1226 #[test]
1234 fn agents_cli_toml_path_is_none_when_agent_directory_escapes_the_project() {
1235 let root = TempDir::new().unwrap();
1236 let project = root.path().join("project");
1237 fs::create_dir(&project).unwrap();
1238 fs::create_dir(root.path().join("elsewhere")).unwrap();
1239 fs::write(
1240 project.join("agents-cli-manifest.yaml"),
1241 "agent_directory: ../elsewhere\n",
1242 )
1243 .unwrap();
1244
1245 assert!(agents_cli_toml_path(&project).is_none());
1246
1247 let r = run(&project, InitOptions::default());
1250 assert_eq!(r.exit, crate::EXIT_OK);
1251 assert!(project.join("keel.toml").exists());
1252 assert!(!root.path().join("elsewhere").join("keel.toml").exists());
1253 }
1254
1255 #[test]
1256 fn diff_reports_added_and_removed_targets_precisely() {
1257 let dir = TempDir::new().unwrap();
1258 fs::write(
1260 dir.path().join("app.mjs"),
1261 "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1262 )
1263 .unwrap();
1264 fs::write(
1266 dir.path().join("keel.toml"),
1267 "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n",
1268 )
1269 .unwrap();
1270
1271 let r = run(
1272 dir.path(),
1273 InitOptions {
1274 diff: true,
1275 stamp: false,
1276 agents: false,
1277 },
1278 );
1279
1280 assert_eq!(r.exit, crate::EXIT_OK);
1281 assert_eq!(
1282 r.json["added"].as_array().unwrap(),
1283 &vec![serde_json::json!("api.example.com")]
1284 );
1285 assert_eq!(
1286 r.json["removed"].as_array().unwrap(),
1287 &vec![serde_json::json!("api.gone.example")]
1288 );
1289 assert!(r.json["unchanged"].as_array().unwrap().is_empty());
1290 assert!(r.human.contains("+ [target.\"api.example.com\"]"));
1291 assert!(r.human.contains("- [target.\"api.gone.example\"]"));
1292 assert_eq!(
1294 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1295 "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n"
1296 );
1297 }
1298
1299 #[test]
1303 fn diff_emits_an_applyable_patch_and_structured_changes() {
1304 let dir = TempDir::new().unwrap();
1305 fs::write(
1306 dir.path().join("app.mjs"),
1307 "// 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",
1308 )
1309 .unwrap();
1310 let old = "\
1311# hand-tuned: keep this comment
1312
1313[target.\"api.example.com\"]
1314timeout = \"9s\" # user tuning survives
1315
1316[target.\"api.gone.example\"] # stale
1317timeout = \"5s\"
1318";
1319 fs::write(dir.path().join("keel.toml"), old).unwrap();
1320
1321 let r = run(
1322 dir.path(),
1323 InitOptions {
1324 diff: true,
1325 stamp: false,
1326 agents: false,
1327 },
1328 );
1329
1330 assert_eq!(r.exit, crate::EXIT_OK);
1331 let patch = r.json["patch"].as_str().unwrap();
1332 assert!(
1333 patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"),
1334 "{patch}"
1335 );
1336 assert!(r.human.contains("apply with `git apply`"));
1337 assert!(
1338 r.human.contains(patch),
1339 "the human output carries the patch verbatim"
1340 );
1341
1342 let applied = crate::diff::apply_unified(old, patch).unwrap();
1343 let value: toml::Value = applied.parse().expect("applied file parses");
1344 assert!(value["target"].get("api.gone.example").is_none());
1345 assert!(value["target"].get("api.new.example").is_some());
1346 assert!(applied.contains("# hand-tuned: keep this comment"));
1347 assert!(applied.contains("timeout = \"9s\" # user tuning survives"));
1348 assert!(applied.contains("[target.\"api.new.example\"]"));
1350 assert!(applied.contains("# seen in: app.mjs:3"));
1351
1352 let changes = r.json["changes"].as_array().unwrap();
1354 let paths: Vec<&str> = changes
1355 .iter()
1356 .map(|c| c["path"].as_str().unwrap())
1357 .collect();
1358 assert_eq!(
1359 paths,
1360 ["target.\"api.gone.example\"", "target.\"api.new.example\""]
1361 );
1362 assert!(changes[0]["after"].is_null());
1363 assert!(changes[1]["before"].is_null());
1364 assert_eq!(
1366 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1367 old
1368 );
1369 }
1370
1371 #[test]
1377 fn diff_skips_dependency_averse_only_hosts_and_says_why() {
1378 if !python3_present() {
1379 eprintln!("skip: python3 not available");
1380 return;
1381 }
1382 let dir = TempDir::new().unwrap();
1383 fs::write(
1384 dir.path().join("risk_gate.py"),
1385 "\"\"\"risk gate. stdlib only.\"\"\"\nimport urllib.request\nU = \"https://api.broker.com/v2\"\n",
1386 )
1387 .unwrap();
1388 fs::write(
1389 dir.path().join("app.py"),
1390 "import httpx\nU = \"https://api.normal.com/v1\"\n",
1391 )
1392 .unwrap();
1393
1394 let r = run(
1395 dir.path(),
1396 InitOptions {
1397 diff: true,
1398 stamp: false,
1399 agents: false,
1400 },
1401 );
1402
1403 assert_eq!(r.exit, crate::EXIT_OK);
1404 let text = &r.human;
1405 assert!(
1406 text.contains("api.normal.com"),
1407 "normal host proposed: {text}"
1408 );
1409 assert!(
1410 !text.contains("[target.\"api.broker.com\"]"),
1411 "no policy for the gate-file host: {text}"
1412 );
1413 assert!(
1414 text.contains("excluded (dependency-averse): api.broker.com"),
1415 "{text}"
1416 );
1417 let added = r.json["added"].as_array().unwrap();
1419 assert!(added.iter().any(|v| v == "api.normal.com"));
1420 assert!(!added.iter().any(|v| v == "api.broker.com"));
1421 }
1422
1423 #[test]
1428 fn init_diff_annotates_simplifications_and_preexisting_resilience() {
1429 if !python3_present() {
1430 eprintln!("skip: python3 not available");
1431 return;
1432 }
1433 let dir = TempDir::new().unwrap();
1434 fs::write(
1435 dir.path().join("app.py"),
1436 r#"import time
1437import httpx
1438import tenacity
1439
1440API = "https://api.normal.com/v1"
1441
1442def caller():
1443 n = 0
1444 while True:
1445 try:
1446 return httpx.get(API)
1447 except Exception:
1448 n += 1
1449 time.sleep(1)
1450"#,
1451 )
1452 .unwrap();
1453 let r = run(
1454 dir.path(),
1455 InitOptions {
1456 diff: true,
1457 stamp: false,
1458 agents: false,
1459 },
1460 );
1461 let json = serde_json::to_string(&r.json).unwrap();
1462 assert!(
1463 json.contains("\"notes\""),
1464 "DiffReport carries notes: {json}"
1465 );
1466 assert!(json.contains("hand-rolled-retry"));
1467 assert!(
1468 json.contains("app.py:9"),
1469 "anchored at the while loop: {json}"
1470 );
1471 assert!(json.contains("tenacity"));
1472 assert!(r.human.contains("# note:"));
1473 assert!(r.human.contains("hand-rolled-retry"));
1474 assert!(r.human.contains("tenacity"));
1475 }
1476
1477 #[test]
1480 fn diff_without_existing_file_is_a_dev_null_creation_patch() {
1481 let dir = TempDir::new().unwrap();
1482 fs::write(
1483 dir.path().join("app.mjs"),
1484 "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1485 )
1486 .unwrap();
1487
1488 let r = run(
1489 dir.path(),
1490 InitOptions {
1491 diff: true,
1492 stamp: false,
1493 agents: false,
1494 },
1495 );
1496
1497 assert_eq!(r.exit, crate::EXIT_OK);
1498 let patch = r.json["patch"].as_str().unwrap();
1499 assert!(
1500 patch.starts_with("--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,"),
1501 "{patch}"
1502 );
1503 let scanned = scan::scan(dir.path());
1504 let expected = render_keel_toml(&scanned, &[], None);
1505 assert_eq!(crate::diff::apply_unified("", patch).unwrap(), expected);
1506 assert_eq!(
1507 r.json["added"].as_array().unwrap(),
1508 &vec![serde_json::json!("api.example.com")]
1509 );
1510 }
1511
1512 #[test]
1515 fn agents_creates_then_is_idempotent() {
1516 let dir = TempDir::new().unwrap();
1517 let opts = InitOptions {
1518 agents: true,
1519 ..InitOptions::default()
1520 };
1521 let r1 = run(dir.path(), opts);
1522 assert_eq!(r1.exit, crate::EXIT_OK);
1523 assert!(r1.json["wrote"].as_bool().unwrap());
1524 let path = dir.path().join("AGENTS.md");
1525 let c1 = fs::read_to_string(&path).unwrap();
1526 assert!(c1.contains("## Keel (resilience & durable execution)"));
1527 assert!(c1.contains("keel doctor --json"));
1528
1529 let r2 = run(dir.path(), opts);
1531 assert!(r2.json["already_current"].as_bool().unwrap());
1532 assert_eq!(fs::read_to_string(&path).unwrap(), c1);
1533 }
1534
1535 #[test]
1536 fn splice_appends_then_replaces_region_without_duplicating() {
1537 let block = agents_block();
1538 let (out, replaced, wrote) = splice_agents_block("# My project\n", &block);
1540 assert!(!replaced && wrote);
1541 assert!(out.starts_with("# My project\n\n"));
1542 assert!(out.contains(AGENTS_BEGIN) && out.contains(AGENTS_END));
1543 let (out2, replaced2, wrote2) = splice_agents_block(&out, &block);
1545 assert!(replaced2 && !wrote2);
1546 assert_eq!(out2, out);
1547 assert_eq!(
1548 out2.matches(AGENTS_BEGIN).count(),
1549 1,
1550 "exactly one Keel block"
1551 );
1552 }
1553
1554 #[test]
1555 fn gitignore_is_created_when_absent() {
1556 let dir = TempDir::new().unwrap();
1557 assert!(update_gitignore(dir.path()).unwrap());
1558 assert_eq!(
1559 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1560 ".keel/\n"
1561 );
1562 }
1563
1564 #[test]
1565 fn gitignore_is_appended_when_keel_line_missing() {
1566 let dir = TempDir::new().unwrap();
1567 fs::write(dir.path().join(".gitignore"), "node_modules/\n*.log").unwrap();
1569
1570 assert!(update_gitignore(dir.path()).unwrap());
1571
1572 assert_eq!(
1573 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1574 "node_modules/\n*.log\n.keel/\n"
1575 );
1576 }
1577
1578 #[test]
1579 fn gitignore_is_a_noop_when_already_ignored() {
1580 let dir = TempDir::new().unwrap();
1581 let original = "build/\n.keel/\ncoverage/\n";
1582 fs::write(dir.path().join(".gitignore"), original).unwrap();
1583
1584 assert!(!update_gitignore(dir.path()).unwrap());
1585
1586 assert_eq!(
1587 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1588 original
1589 );
1590 }
1591
1592 #[test]
1601 fn agents_cli_toml_path_rejects_absolute_path_agent_directory() {
1602 let root = TempDir::new().unwrap();
1603 let project = root.path().join("project");
1604 fs::create_dir(&project).unwrap();
1605 let outside = root.path().join("outside");
1606 fs::create_dir(&outside).unwrap();
1607
1608 fs::write(
1609 project.join("agents-cli-manifest.yaml"),
1610 format!("agent_directory: {}\n", outside.display()),
1613 )
1614 .unwrap();
1615
1616 assert!(agents_cli_toml_path(&project).is_none());
1619
1620 let r = run(&project, InitOptions::default());
1622 assert_eq!(r.exit, crate::EXIT_OK);
1623 assert!(project.join("keel.toml").exists());
1624 assert!(!outside.join("keel.toml").exists());
1625 }
1626
1627 #[cfg(unix)]
1635 #[test]
1636 fn agents_cli_toml_path_rejects_symlink_escape_to_outside_project() {
1637 use std::os::unix::fs as unix_fs;
1638
1639 let root = TempDir::new().unwrap();
1640 let project = root.path().join("project");
1641 fs::create_dir(&project).unwrap();
1642 let outside = root.path().join("outside");
1643 fs::create_dir(&outside).unwrap();
1644
1645 let symlink = project.join("app");
1647 unix_fs::symlink(&outside, &symlink).unwrap();
1648
1649 fs::write(
1650 project.join("agents-cli-manifest.yaml"),
1651 "agent_directory: app\n",
1652 )
1653 .unwrap();
1654
1655 assert!(agents_cli_toml_path(&project).is_none());
1658
1659 let r = run(&project, InitOptions::default());
1661 assert_eq!(r.exit, crate::EXIT_OK);
1662 assert!(project.join("keel.toml").exists());
1663 assert!(!outside.join("keel.toml").exists());
1664 }
1665}