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();
570 if !scan.resilience_libs.is_empty()
571 && scan.libs.iter().any(|l| registry_libs.contains(l.as_str()))
572 {
573 let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
574 notes.push(format!(
575 "pre-existing resilience: this project imports {} — once Keel wraps the same calls, \
576 delete the old retry/backoff or scope Keel's policy (see `keel doctor`)",
577 libs.join(", ")
578 ));
579 }
580 notes
581}
582
583fn render_diff_trailer(excluded: &[crate::doctor::TopologyEntry], notes: &[String]) -> String {
587 let mut out = String::new();
588 if !excluded.is_empty() {
589 let mut excluded: Vec<_> = excluded.iter().collect();
594 excluded.sort_by(|a, b| a.host.cmp(&b.host));
595 out.push('\n');
596 for entry in excluded {
597 let line = format!(
598 "# excluded (dependency-averse): {} — {}\n",
599 entry.host, entry.reason
600 );
601 out.push_str(&line);
602 }
603 }
604 if !notes.is_empty() {
605 out.push('\n');
606 for note in notes {
607 let line = format!("# note: {note}\n");
608 out.push_str(&line);
609 }
610 }
611 out
612}
613
614fn diff(
630 toml_path: &Path,
631 scan: &ScanResult,
632 discovery: &[TargetStats],
633 generated: &[String],
634 stamp: Option<&str>,
635) -> Rendered {
636 let existing_text = match read_existing(toml_path) {
637 Ok(t) => t,
638 Err(e) => return config_error(&e),
639 };
640 let existing = match existing_text
641 .as_deref()
642 .map(|text| existing_targets(text, toml_path))
643 .transpose()
644 {
645 Ok(set) => set.unwrap_or_default(),
646 Err(e) => return config_error(&e),
647 };
648
649 let wrapped_targets: BTreeSet<String> = discovery.iter().map(|s| s.target.clone()).collect();
650 let topology = crate::doctor::classify_topology(scan, &wrapped_targets);
651 let excluded_hosts: BTreeSet<&str> =
652 topology.excluded.iter().map(|e| e.host.as_str()).collect();
653
654 let generated_set: BTreeSet<&str> = generated.iter().map(String::as_str).collect();
655 let added: Vec<String> = generated_set
656 .iter()
657 .filter(|t| !existing.contains(**t) && !excluded_hosts.contains(**t))
658 .map(|t| (*t).to_owned())
659 .collect();
660 let removed: Vec<String> = existing
661 .iter()
662 .filter(|t| !generated_set.contains(t.as_str()))
663 .cloned()
664 .collect();
665 let unchanged: Vec<String> = generated_set
666 .iter()
667 .filter(|t| existing.contains(**t))
668 .map(|t| (*t).to_owned())
669 .collect();
670 let notes = diff_notes(scan, &added);
671
672 let ops = if existing_text.is_none() {
673 vec![PolicyOp::AppendBlock {
677 text: render_keel_toml_for_targets(scan, discovery, &added, stamp),
678 }]
679 } else {
680 let by_target: BTreeMap<&str, &TargetStats> =
681 discovery.iter().map(|s| (s.target.as_str(), s)).collect();
682 let mut ops: Vec<PolicyOp> = removed
683 .iter()
684 .map(|t| PolicyOp::Remove {
685 path: PolicyPath::new(["target", t.as_str()]),
686 })
687 .collect();
688 ops.extend(added.iter().map(|t| PolicyOp::AppendBlock {
689 text: render_target_block(t, scan.targets.get(t), by_target.get(t.as_str()).copied()),
690 }));
691 ops
692 };
693 let proposal = match propose(existing_text.as_deref(), &ops) {
694 Ok(p) => p,
695 Err(e) => return config_error(&e.to_string()),
696 };
697
698 let mut human = String::from("keel \u{25b8} keel init --diff\n");
699 if added.is_empty() && removed.is_empty() {
700 if topology.excluded.is_empty() {
701 human.push_str(" no changes: every discovered target is already in keel.toml.\n");
702 } else {
703 human.push_str(
704 " no policy changes: every discovered target is already in keel.toml or excluded below.\n",
705 );
706 }
707 } else {
708 for t in &added {
709 let line = format!(" + [target.\"{t}\"] (found in code, not in keel.toml)\n");
710 human.push_str(&line);
711 }
712 for t in &removed {
713 let line = format!(" - [target.\"{t}\"] (in keel.toml, no longer found in code)\n");
714 human.push_str(&line);
715 }
716 }
717 if !proposal.patch.is_empty() {
718 human.push_str("\napply with `git apply` (or `patch -p1`):\n\n");
719 human.push_str(&proposal.patch);
720 }
721 human.push_str(&render_diff_trailer(&topology.excluded, ¬es));
722 let report = DiffReport {
723 added,
724 changes: proposal.changes,
725 notes,
726 patch: proposal.patch,
727 removed,
728 unchanged,
729 };
730 Rendered::ok(human, to_json(&report))
731}
732
733fn read_existing(toml_path: &Path) -> Result<Option<String>, String> {
736 if !toml_path.exists() {
737 return Ok(None);
738 }
739 std::fs::read_to_string(toml_path)
740 .map(Some)
741 .map_err(|e| format!("could not read {}: {e}", toml_path.display()))
742}
743
744fn existing_targets(text: &str, toml_path: &Path) -> Result<BTreeSet<String>, String> {
746 let value: toml::Value = text
747 .parse()
748 .map_err(|e| format!("{} is not valid TOML: {e}", toml_path.display()))?;
749 let mut set = BTreeSet::new();
750 if let Some(table) = value.get("target").and_then(toml::Value::as_table) {
751 for key in table.keys() {
752 set.insert(key.clone());
753 }
754 }
755 Ok(set)
756}
757
758fn update_gitignore(project: &Path) -> std::io::Result<bool> {
761 let path = project.join(".gitignore");
762 if !path.exists() {
763 std::fs::write(&path, ".keel/\n")?;
764 return Ok(true);
765 }
766 let text = std::fs::read_to_string(&path)?;
767 let ignored = text
768 .lines()
769 .map(str::trim)
770 .any(|l| l == ".keel" || l == ".keel/");
771 if ignored {
772 return Ok(false);
773 }
774 let mut updated = text;
775 if !updated.ends_with('\n') && !updated.is_empty() {
776 updated.push('\n');
777 }
778 updated.push_str(".keel/\n");
779 std::fs::write(&path, updated)?;
780 Ok(true)
781}
782
783pub(crate) fn has_python_files(project: &Path) -> bool {
787 let mut found = Vec::new();
788 scan::collect_files(project, &["py"], &mut found);
789 !found.is_empty()
790}
791
792fn config_error(message: &str) -> Rendered {
794 #[derive(Serialize)]
795 struct ErrReport<'a> {
796 code: &'static str,
797 error: &'a str,
798 }
799 let human = format!("keel \u{25b8} KEEL-E001: {message}");
800 Rendered {
801 human,
802 json: to_json(&ErrReport {
803 code: "KEEL-E001",
804 error: message,
805 }),
806 exit: EXIT_USAGE,
807 to_stderr: true,
808 }
809 .with_exit(EXIT_USAGE)
810}
811
812pub(crate) fn plural(n: usize) -> &'static str {
814 if n == 1 { "" } else { "s" }
815}
816
817fn today_utc() -> String {
821 let secs = std::time::SystemTime::now()
822 .duration_since(std::time::UNIX_EPOCH)
823 .map_or(0, |d| d.as_secs());
824 let days = i64::try_from(secs / 86_400).unwrap_or(0);
825 let (y, m, d) = civil_from_days(days);
826 format!("{y:04}-{m:02}-{d:02}")
827}
828
829fn civil_from_days(z: i64) -> (i64, u32, u32) {
831 let z = z + 719_468;
832 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
833 let doe = z - era * 146_097;
834 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
835 let y = yoe + era * 400;
836 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
837 let mp = (5 * doy + 2) / 153;
838 let d = doy - (153 * mp + 2) / 5 + 1;
839 let m = if mp < 10 { mp + 3 } else { mp - 9 };
840 let y = if m <= 2 { y + 1 } else { y };
841 #[expect(
842 clippy::cast_sign_loss,
843 clippy::cast_possible_truncation,
844 reason = "m,d in 1..=31"
845 )]
846 (y, m as u32, d as u32)
847}
848
849#[cfg(test)]
850mod tests {
851 use std::fs;
852 use std::process::{Command, Stdio};
853
854 use keel_journal::ErrorClass;
855 use tempfile::TempDir;
856
857 use super::*;
858
859 fn python3_present() -> bool {
863 Command::new("python3")
864 .arg("--version")
865 .stdout(Stdio::null())
866 .stderr(Stdio::null())
867 .status()
868 .is_ok_and(|s| s.success())
869 }
870
871 fn llm_stats(calls: i64, first_seen_ms: i64, last_seen_ms: i64) -> TargetStats {
874 TargetStats {
875 target: "llm:openai".to_owned(),
876 calls,
877 attempts: calls,
878 retries: 0,
879 successes: calls,
880 failures: 0,
881 cache_hits: 0,
882 throttled: 0,
883 breaker_opens: 0,
884 total_latency_ms: 0,
885 max_latency_ms: 0,
886 first_seen_ms,
887 last_seen_ms,
888 last_error_class: None,
889 last_error_status: None,
890 not_retried: 0,
891 unwrapped_calls: 0,
892 }
893 }
894
895 fn host_scan() -> ScanResult {
896 let mut s = ScanResult {
897 files_scanned: 2,
898 python_available: true,
899 ..ScanResult::default()
900 };
901 s.targets.insert(
903 "api.example.com".to_owned(),
904 TargetEvidence {
905 class: TargetClass::Host,
906 sightings: [scan::Sighting {
907 file: "app.py".into(),
908 line: 4,
909 }]
910 .into_iter()
911 .collect(),
912 },
913 );
914 s.targets.insert(
915 "llm:openai".to_owned(),
916 TargetEvidence {
917 class: TargetClass::Llm,
918 sightings: [scan::Sighting {
919 file: "app.py".into(),
920 line: 2,
921 }]
922 .into_iter()
923 .collect(),
924 },
925 );
926 s
927 }
928
929 #[test]
930 fn header_counts_and_no_date_by_default() {
931 let out = render_keel_toml(&host_scan(), &[], None);
932 assert!(
933 out.starts_with("# Generated by keel init from 2 static scans + 0 observed runs\n")
934 );
935 assert!(!out.contains("202"), "no date without --stamp");
936 }
937
938 #[test]
939 fn stamp_adds_a_date() {
940 let out = render_keel_toml(&host_scan(), &[], Some("2026-07-12"));
941 assert!(out.lines().next().unwrap().ends_with(" (2026-07-12)"));
942 }
943
944 #[test]
945 fn host_and_llm_blocks_cite_evidence() {
946 let out = render_keel_toml(&host_scan(), &[], None);
947 assert!(out.contains("[target.\"api.example.com\"]"));
948 assert!(out.contains("# seen in: app.py:4"));
949 assert!(out.contains("[target.\"llm:openai\"]"));
950 assert!(out.contains("# seen in: app.py:2"));
951 assert!(out.contains("cache = { mode = \"dev\" }"));
952 }
953
954 #[test]
955 fn discovery_only_target_is_surfaced_with_observed_comment() {
956 let scan = ScanResult {
957 files_scanned: 1,
958 python_available: true,
959 ..ScanResult::default()
960 };
961 let stats = TargetStats {
962 target: "api.dynamic.com".to_owned(),
963 calls: 120,
964 attempts: 132,
965 retries: 12,
966 successes: 120,
967 failures: 0,
968 cache_hits: 0,
969 throttled: 0,
970 breaker_opens: 0,
971 total_latency_ms: 12_000,
972 max_latency_ms: 300,
973 first_seen_ms: 0,
974 last_seen_ms: 120_000, last_error_class: None,
976 last_error_status: None,
977 not_retried: 0,
978 unwrapped_calls: 0,
979 };
980 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
981 assert!(out.contains("[target.\"api.dynamic.com\"]"));
982 assert!(out.contains("# seen only at runtime (.keel/discovery.db)"));
983 assert!(out.contains("# observed: 120 calls, 12 retries, ~60.0/min mean"));
984 assert!(out.contains("+ 1 observed run\n"));
986 }
987
988 #[test]
989 fn error_class_import_is_available() {
990 let _ = ErrorClass::Http;
992 }
993
994 #[test]
995 fn default_body_matches_the_frozen_pack() {
996 let defaults: toml::Value = include_str!("../contract/defaults.toml")
998 .parse()
999 .expect("defaults.toml parses");
1000 let outbound = &defaults["defaults"]["outbound"];
1001 assert_eq!(outbound["timeout"].as_str(), Some("30s"));
1002 assert_eq!(outbound["retry"]["attempts"].as_integer(), Some(3));
1003 assert_eq!(outbound["breaker"]["cooldown"].as_str(), Some("15s"));
1004 let llm = &defaults["defaults"]["llm"];
1005 assert_eq!(llm["timeout"].as_str(), Some("120s"));
1006 assert_eq!(llm["retry"]["attempts"].as_integer(), Some(6));
1007 assert_eq!(llm["breaker"]["cooldown"].as_str(), Some("30s"));
1008 assert_eq!(llm["cache"]["mode"].as_str(), Some("dev"));
1009 assert!(policy_body(TargetClass::Host).contains("attempts = 3"));
1011 assert!(policy_body(TargetClass::Host).contains("cooldown = \"15s\""));
1012 assert!(policy_body(TargetClass::Llm).contains("attempts = 6"));
1013 assert!(policy_body(TargetClass::Llm).contains("cooldown = \"30s\""));
1014 assert!(policy_body(TargetClass::Llm).contains("mode = \"dev\""));
1015 }
1016
1017 #[test]
1018 fn civil_date_epoch_is_1970_01_01() {
1019 assert_eq!(civil_from_days(0), (1970, 1, 1));
1020 assert_eq!(civil_from_days(19_997), (2024, 10, 1));
1021 }
1022
1023 #[test]
1026 fn round_up_clean_walks_the_1_2_5_series() {
1027 assert_eq!(round_up_clean(0), 0);
1028 assert_eq!(round_up_clean(1), 1);
1029 assert_eq!(round_up_clean(3), 5);
1030 assert_eq!(round_up_clean(6), 10);
1031 assert_eq!(round_up_clean(11), 20);
1032 assert_eq!(round_up_clean(50), 50);
1033 assert_eq!(round_up_clean(60), 100);
1034 assert_eq!(round_up_clean(123), 200);
1035 assert_eq!(round_up_clean(300), 500);
1036 assert_eq!(round_up_clean(501), 1_000);
1037 }
1038
1039 #[test]
1040 fn llm_rate_is_mean_times_three_rounded_up_to_a_clean_value() {
1041 let s = llm_stats(200, 0, 120_000);
1043 assert_eq!(mean_per_min_floor(&s), 100);
1044 assert_eq!(llm_rate_per_min(&s), 500);
1045 }
1046
1047 #[test]
1048 fn llm_rate_floors_at_60_for_sparse_traffic() {
1049 let s = llm_stats(5, 0, 60_000);
1051 assert_eq!(mean_per_min_floor(&s), 5);
1052 assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
1053 }
1054
1055 #[test]
1056 fn llm_rate_zero_span_window_falls_back_to_floor() {
1057 let s = llm_stats(500, 1_000, 1_000);
1059 assert_eq!(mean_per_min_floor(&s), 0);
1060 assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
1061 }
1062
1063 #[test]
1064 fn observed_llm_target_gets_an_active_rate_line() {
1065 let scan = ScanResult {
1066 files_scanned: 1,
1067 python_available: true,
1068 ..ScanResult::default()
1069 };
1070 let stats = llm_stats(200, 0, 120_000);
1071 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1072
1073 assert!(out.contains("[target.\"llm:openai\"]"));
1074 assert!(out.contains("rate = \"500/min\""));
1075 assert!(out.contains("# headroom over your observed mean of ~100/min"));
1076 assert!(!out.contains("peak"));
1078 let rate_at = out.find("rate =").expect("rate line present");
1080 let cache_at = out.find("cache =").expect("cache line present");
1081 assert!(rate_at < cache_at, "rate must precede cache");
1082 }
1083
1084 #[test]
1085 fn zero_span_llm_target_emits_floor_with_honest_comment() {
1086 let scan = ScanResult {
1087 files_scanned: 1,
1088 python_available: true,
1089 ..ScanResult::default()
1090 };
1091 let stats = llm_stats(9, 5_000, 5_000);
1092 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1093 assert!(out.contains("rate = \"60/min\""));
1094 assert!(out.contains("# floor: single observation window, no mean to derive"));
1095 assert!(!out.contains("peak"));
1096 }
1097
1098 #[test]
1099 fn observed_host_target_stays_comments_only() {
1100 let scan = ScanResult {
1102 files_scanned: 1,
1103 python_available: true,
1104 ..ScanResult::default()
1105 };
1106 let stats = TargetStats {
1107 target: "api.host.example".to_owned(),
1108 ..llm_stats(200, 0, 120_000)
1109 };
1110 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
1111 assert!(out.contains("[target.\"api.host.example\"]"));
1112 assert!(
1113 !out.contains("rate ="),
1114 "host targets must not emit an active rate line"
1115 );
1116 }
1117
1118 #[test]
1121 fn refuses_when_keel_toml_already_exists() {
1122 let dir = TempDir::new().unwrap();
1123 fs::write(dir.path().join("keel.toml"), "# hand-written\n").unwrap();
1124
1125 let r = run(dir.path(), InitOptions::default());
1126
1127 assert_eq!(r.exit, EXIT_USAGE);
1128 assert!(r.to_stderr);
1129 assert!(r.human.contains("already exists"));
1130 assert_eq!(
1132 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1133 "# hand-written\n"
1134 );
1135 }
1136
1137 #[test]
1143 fn agents_cli_project_writes_keel_toml_into_the_agent_dir() {
1144 let dir = TempDir::new().unwrap();
1145 fs::create_dir(dir.path().join("app")).unwrap();
1146 fs::write(
1147 dir.path().join("agents-cli-manifest.yaml"),
1148 "schema_version: 1\nagent_directory: app\n",
1149 )
1150 .unwrap();
1151
1152 let r = run(dir.path(), InitOptions::default());
1153
1154 assert_eq!(r.exit, crate::EXIT_OK);
1155 assert!(!dir.path().join("keel.toml").exists(), "no root keel.toml");
1156 assert!(
1157 dir.path().join("app").join("keel.toml").exists(),
1158 "keel.toml lands in the agent directory"
1159 );
1160 assert_eq!(
1161 r.json["wrote"].as_str().unwrap(),
1162 dir.path()
1163 .join("app")
1164 .join("keel.toml")
1165 .display()
1166 .to_string()
1167 );
1168 }
1169
1170 #[test]
1174 fn non_agents_cli_project_writes_keel_toml_at_the_root() {
1175 let dir = TempDir::new().unwrap();
1176
1177 let r = run(dir.path(), InitOptions::default());
1178
1179 assert_eq!(r.exit, crate::EXIT_OK);
1180 assert!(dir.path().join("keel.toml").exists());
1181 }
1182
1183 #[test]
1187 fn agents_cli_refuses_when_the_redirected_path_already_exists() {
1188 let dir = TempDir::new().unwrap();
1189 fs::create_dir(dir.path().join("app")).unwrap();
1190 fs::write(
1191 dir.path().join("agents-cli-manifest.yaml"),
1192 "agent_directory: app\n",
1193 )
1194 .unwrap();
1195 fs::write(dir.path().join("app").join("keel.toml"), "# hand-written\n").unwrap();
1196
1197 let r = run(dir.path(), InitOptions::default());
1198
1199 assert_eq!(r.exit, EXIT_USAGE);
1200 assert!(r.human.contains("already exists"));
1201 assert_eq!(
1202 fs::read_to_string(dir.path().join("app").join("keel.toml")).unwrap(),
1203 "# hand-written\n"
1204 );
1205 }
1206
1207 #[test]
1209 fn agents_cli_toml_path_is_none_without_a_manifest() {
1210 let dir = TempDir::new().unwrap();
1211 assert!(agents_cli_toml_path(dir.path()).is_none());
1212 }
1213
1214 #[test]
1222 fn agents_cli_toml_path_is_none_when_agent_directory_escapes_the_project() {
1223 let root = TempDir::new().unwrap();
1224 let project = root.path().join("project");
1225 fs::create_dir(&project).unwrap();
1226 fs::create_dir(root.path().join("elsewhere")).unwrap();
1227 fs::write(
1228 project.join("agents-cli-manifest.yaml"),
1229 "agent_directory: ../elsewhere\n",
1230 )
1231 .unwrap();
1232
1233 assert!(agents_cli_toml_path(&project).is_none());
1234
1235 let r = run(&project, InitOptions::default());
1238 assert_eq!(r.exit, crate::EXIT_OK);
1239 assert!(project.join("keel.toml").exists());
1240 assert!(!root.path().join("elsewhere").join("keel.toml").exists());
1241 }
1242
1243 #[test]
1244 fn diff_reports_added_and_removed_targets_precisely() {
1245 let dir = TempDir::new().unwrap();
1246 fs::write(
1248 dir.path().join("app.mjs"),
1249 "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1250 )
1251 .unwrap();
1252 fs::write(
1254 dir.path().join("keel.toml"),
1255 "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n",
1256 )
1257 .unwrap();
1258
1259 let r = run(
1260 dir.path(),
1261 InitOptions {
1262 diff: true,
1263 stamp: false,
1264 agents: false,
1265 },
1266 );
1267
1268 assert_eq!(r.exit, crate::EXIT_OK);
1269 assert_eq!(
1270 r.json["added"].as_array().unwrap(),
1271 &vec![serde_json::json!("api.example.com")]
1272 );
1273 assert_eq!(
1274 r.json["removed"].as_array().unwrap(),
1275 &vec![serde_json::json!("api.gone.example")]
1276 );
1277 assert!(r.json["unchanged"].as_array().unwrap().is_empty());
1278 assert!(r.human.contains("+ [target.\"api.example.com\"]"));
1279 assert!(r.human.contains("- [target.\"api.gone.example\"]"));
1280 assert_eq!(
1282 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1283 "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n"
1284 );
1285 }
1286
1287 #[test]
1291 fn diff_emits_an_applyable_patch_and_structured_changes() {
1292 let dir = TempDir::new().unwrap();
1293 fs::write(
1294 dir.path().join("app.mjs"),
1295 "// 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",
1296 )
1297 .unwrap();
1298 let old = "\
1299# hand-tuned: keep this comment
1300
1301[target.\"api.example.com\"]
1302timeout = \"9s\" # user tuning survives
1303
1304[target.\"api.gone.example\"] # stale
1305timeout = \"5s\"
1306";
1307 fs::write(dir.path().join("keel.toml"), old).unwrap();
1308
1309 let r = run(
1310 dir.path(),
1311 InitOptions {
1312 diff: true,
1313 stamp: false,
1314 agents: false,
1315 },
1316 );
1317
1318 assert_eq!(r.exit, crate::EXIT_OK);
1319 let patch = r.json["patch"].as_str().unwrap();
1320 assert!(
1321 patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"),
1322 "{patch}"
1323 );
1324 assert!(r.human.contains("apply with `git apply`"));
1325 assert!(
1326 r.human.contains(patch),
1327 "the human output carries the patch verbatim"
1328 );
1329
1330 let applied = crate::diff::apply_unified(old, patch).unwrap();
1331 let value: toml::Value = applied.parse().expect("applied file parses");
1332 assert!(value["target"].get("api.gone.example").is_none());
1333 assert!(value["target"].get("api.new.example").is_some());
1334 assert!(applied.contains("# hand-tuned: keep this comment"));
1335 assert!(applied.contains("timeout = \"9s\" # user tuning survives"));
1336 assert!(applied.contains("[target.\"api.new.example\"]"));
1338 assert!(applied.contains("# seen in: app.mjs:3"));
1339
1340 let changes = r.json["changes"].as_array().unwrap();
1342 let paths: Vec<&str> = changes
1343 .iter()
1344 .map(|c| c["path"].as_str().unwrap())
1345 .collect();
1346 assert_eq!(
1347 paths,
1348 ["target.\"api.gone.example\"", "target.\"api.new.example\""]
1349 );
1350 assert!(changes[0]["after"].is_null());
1351 assert!(changes[1]["before"].is_null());
1352 assert_eq!(
1354 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1355 old
1356 );
1357 }
1358
1359 #[test]
1365 fn diff_skips_dependency_averse_only_hosts_and_says_why() {
1366 if !python3_present() {
1367 eprintln!("skip: python3 not available");
1368 return;
1369 }
1370 let dir = TempDir::new().unwrap();
1371 fs::write(
1372 dir.path().join("risk_gate.py"),
1373 "\"\"\"risk gate. stdlib only.\"\"\"\nimport urllib.request\nU = \"https://api.broker.com/v2\"\n",
1374 )
1375 .unwrap();
1376 fs::write(
1377 dir.path().join("app.py"),
1378 "import httpx\nU = \"https://api.normal.com/v1\"\n",
1379 )
1380 .unwrap();
1381
1382 let r = run(
1383 dir.path(),
1384 InitOptions {
1385 diff: true,
1386 stamp: false,
1387 agents: false,
1388 },
1389 );
1390
1391 assert_eq!(r.exit, crate::EXIT_OK);
1392 let text = &r.human;
1393 assert!(
1394 text.contains("api.normal.com"),
1395 "normal host proposed: {text}"
1396 );
1397 assert!(
1398 !text.contains("[target.\"api.broker.com\"]"),
1399 "no policy for the gate-file host: {text}"
1400 );
1401 assert!(
1402 text.contains("excluded (dependency-averse): api.broker.com"),
1403 "{text}"
1404 );
1405 let added = r.json["added"].as_array().unwrap();
1407 assert!(added.iter().any(|v| v == "api.normal.com"));
1408 assert!(!added.iter().any(|v| v == "api.broker.com"));
1409 }
1410
1411 #[test]
1416 fn init_diff_annotates_simplifications_and_preexisting_resilience() {
1417 if !python3_present() {
1418 eprintln!("skip: python3 not available");
1419 return;
1420 }
1421 let dir = TempDir::new().unwrap();
1422 fs::write(
1423 dir.path().join("app.py"),
1424 r#"import time
1425import httpx
1426import tenacity
1427
1428API = "https://api.normal.com/v1"
1429
1430def caller():
1431 n = 0
1432 while True:
1433 try:
1434 return httpx.get(API)
1435 except Exception:
1436 n += 1
1437 time.sleep(1)
1438"#,
1439 )
1440 .unwrap();
1441 let r = run(
1442 dir.path(),
1443 InitOptions {
1444 diff: true,
1445 stamp: false,
1446 agents: false,
1447 },
1448 );
1449 let json = serde_json::to_string(&r.json).unwrap();
1450 assert!(
1451 json.contains("\"notes\""),
1452 "DiffReport carries notes: {json}"
1453 );
1454 assert!(json.contains("hand-rolled-retry"));
1455 assert!(
1456 json.contains("app.py:9"),
1457 "anchored at the while loop: {json}"
1458 );
1459 assert!(json.contains("tenacity"));
1460 assert!(r.human.contains("# note:"));
1461 assert!(r.human.contains("hand-rolled-retry"));
1462 assert!(r.human.contains("tenacity"));
1463 }
1464
1465 #[test]
1468 fn diff_without_existing_file_is_a_dev_null_creation_patch() {
1469 let dir = TempDir::new().unwrap();
1470 fs::write(
1471 dir.path().join("app.mjs"),
1472 "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1473 )
1474 .unwrap();
1475
1476 let r = run(
1477 dir.path(),
1478 InitOptions {
1479 diff: true,
1480 stamp: false,
1481 agents: false,
1482 },
1483 );
1484
1485 assert_eq!(r.exit, crate::EXIT_OK);
1486 let patch = r.json["patch"].as_str().unwrap();
1487 assert!(
1488 patch.starts_with("--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,"),
1489 "{patch}"
1490 );
1491 let scanned = scan::scan(dir.path());
1492 let expected = render_keel_toml(&scanned, &[], None);
1493 assert_eq!(crate::diff::apply_unified("", patch).unwrap(), expected);
1494 assert_eq!(
1495 r.json["added"].as_array().unwrap(),
1496 &vec![serde_json::json!("api.example.com")]
1497 );
1498 }
1499
1500 #[test]
1503 fn agents_creates_then_is_idempotent() {
1504 let dir = TempDir::new().unwrap();
1505 let opts = InitOptions {
1506 agents: true,
1507 ..InitOptions::default()
1508 };
1509 let r1 = run(dir.path(), opts);
1510 assert_eq!(r1.exit, crate::EXIT_OK);
1511 assert!(r1.json["wrote"].as_bool().unwrap());
1512 let path = dir.path().join("AGENTS.md");
1513 let c1 = fs::read_to_string(&path).unwrap();
1514 assert!(c1.contains("## Keel (resilience & durable execution)"));
1515 assert!(c1.contains("keel doctor --json"));
1516
1517 let r2 = run(dir.path(), opts);
1519 assert!(r2.json["already_current"].as_bool().unwrap());
1520 assert_eq!(fs::read_to_string(&path).unwrap(), c1);
1521 }
1522
1523 #[test]
1524 fn splice_appends_then_replaces_region_without_duplicating() {
1525 let block = agents_block();
1526 let (out, replaced, wrote) = splice_agents_block("# My project\n", &block);
1528 assert!(!replaced && wrote);
1529 assert!(out.starts_with("# My project\n\n"));
1530 assert!(out.contains(AGENTS_BEGIN) && out.contains(AGENTS_END));
1531 let (out2, replaced2, wrote2) = splice_agents_block(&out, &block);
1533 assert!(replaced2 && !wrote2);
1534 assert_eq!(out2, out);
1535 assert_eq!(
1536 out2.matches(AGENTS_BEGIN).count(),
1537 1,
1538 "exactly one Keel block"
1539 );
1540 }
1541
1542 #[test]
1543 fn gitignore_is_created_when_absent() {
1544 let dir = TempDir::new().unwrap();
1545 assert!(update_gitignore(dir.path()).unwrap());
1546 assert_eq!(
1547 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1548 ".keel/\n"
1549 );
1550 }
1551
1552 #[test]
1553 fn gitignore_is_appended_when_keel_line_missing() {
1554 let dir = TempDir::new().unwrap();
1555 fs::write(dir.path().join(".gitignore"), "node_modules/\n*.log").unwrap();
1557
1558 assert!(update_gitignore(dir.path()).unwrap());
1559
1560 assert_eq!(
1561 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1562 "node_modules/\n*.log\n.keel/\n"
1563 );
1564 }
1565
1566 #[test]
1567 fn gitignore_is_a_noop_when_already_ignored() {
1568 let dir = TempDir::new().unwrap();
1569 let original = "build/\n.keel/\ncoverage/\n";
1570 fs::write(dir.path().join(".gitignore"), original).unwrap();
1571
1572 assert!(!update_gitignore(dir.path()).unwrap());
1573
1574 assert_eq!(
1575 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1576 original
1577 );
1578 }
1579
1580 #[test]
1589 fn agents_cli_toml_path_rejects_absolute_path_agent_directory() {
1590 let root = TempDir::new().unwrap();
1591 let project = root.path().join("project");
1592 fs::create_dir(&project).unwrap();
1593 let outside = root.path().join("outside");
1594 fs::create_dir(&outside).unwrap();
1595
1596 fs::write(
1597 project.join("agents-cli-manifest.yaml"),
1598 format!("agent_directory: {}\n", outside.display()),
1601 )
1602 .unwrap();
1603
1604 assert!(agents_cli_toml_path(&project).is_none());
1607
1608 let r = run(&project, InitOptions::default());
1610 assert_eq!(r.exit, crate::EXIT_OK);
1611 assert!(project.join("keel.toml").exists());
1612 assert!(!outside.join("keel.toml").exists());
1613 }
1614
1615 #[cfg(unix)]
1623 #[test]
1624 fn agents_cli_toml_path_rejects_symlink_escape_to_outside_project() {
1625 use std::os::unix::fs as unix_fs;
1626
1627 let root = TempDir::new().unwrap();
1628 let project = root.path().join("project");
1629 fs::create_dir(&project).unwrap();
1630 let outside = root.path().join("outside");
1631 fs::create_dir(&outside).unwrap();
1632
1633 let symlink = project.join("app");
1635 unix_fs::symlink(&outside, &symlink).unwrap();
1636
1637 fs::write(
1638 project.join("agents-cli-manifest.yaml"),
1639 "agent_directory: app\n",
1640 )
1641 .unwrap();
1642
1643 assert!(agents_cli_toml_path(&project).is_none());
1646
1647 let r = run(&project, InitOptions::default());
1649 assert_eq!(r.exit, crate::EXIT_OK);
1650 assert!(project.join("keel.toml").exists());
1651 assert!(!outside.join("keel.toml").exists());
1652 }
1653}