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 patch: String,
152 removed: Vec<String>,
153 unchanged: Vec<String>,
154}
155
156pub fn run(project: &Path, opts: InitOptions) -> Rendered {
158 if opts.agents {
159 return run_agents(project);
160 }
161 let scan = scan::scan(project);
162 let discovery = match evidence::read_discovery(project) {
163 Ok(d) => d,
164 Err(e) => return config_error(&e),
165 };
166
167 let content = render_keel_toml(&scan, &discovery, opts.stamp.then(today_utc).as_deref());
168 let targets = merged_targets(&scan, &discovery);
169
170 let agents_cli_path = agents_cli_toml_path(project);
171 let toml_path = agents_cli_path
172 .clone()
173 .unwrap_or_else(|| evidence::keel_toml(project));
174 if opts.diff {
175 return diff(&toml_path, &scan, &discovery, &targets, &content);
176 }
177 if toml_path.exists() {
178 return config_error(&format!(
179 "{} already exists. Run `keel init --diff` to preview changes, or edit it directly.",
180 toml_path.display()
181 ));
182 }
183
184 if let Err(e) = std::fs::write(&toml_path, &content) {
185 return config_error(&format!("could not write {}: {e}", toml_path.display()));
186 }
187 if agents_cli_path.is_some() {
192 eprintln!(
193 "keel \u{25b8} agents-cli project detected \u{2014} writing {} so it ships in the \
194 container image",
195 relative_display(project, &toml_path)
196 );
197 }
198 let gitignore_updated = update_gitignore(project).unwrap_or(false);
199
200 let mut warnings = String::new();
201 if !scan.python_available && has_python_files(project) {
202 warnings.push_str(
203 "\nkeel \u{25b8} note: python3 was not found; Python files were not scanned.\n",
204 );
205 }
206
207 let observed_runs = u32::from(!discovery.is_empty());
208 let human = format!(
209 "keel \u{25b8} wrote {} ({} target{}) from {} static scan{} + {} observed run{}.{}",
210 toml_path.display(),
211 targets.len(),
212 plural(targets.len()),
213 scan.files_scanned,
214 plural(scan.files_scanned),
215 observed_runs,
216 plural(observed_runs as usize),
217 if warnings.is_empty() {
218 String::new()
219 } else {
220 warnings
221 }
222 );
223 let report = WroteReport {
224 gitignore_updated,
225 observed_runs,
226 static_scans: scan.files_scanned,
227 targets,
228 wrote: toml_path.display().to_string(),
229 };
230 Rendered::ok(human, to_json(&report))
231}
232
233fn agents_cli_toml_path(project: &Path) -> Option<std::path::PathBuf> {
257 let layout = agents_cli::find_agents_cli_layout(project)?;
258 let canonical_project = std::fs::canonicalize(project).ok()?;
259 let canonical_agent_dir = std::fs::canonicalize(&layout.agent_dir).ok()?;
260 if !canonical_agent_dir.starts_with(&canonical_project) {
261 return None;
262 }
263 Some(layout.agent_dir.join("keel.toml"))
264}
265
266fn relative_display(base: &Path, target: &Path) -> String {
271 target.strip_prefix(base).map_or_else(
272 |_| target.display().to_string(),
273 |rel| rel.display().to_string(),
274 )
275}
276
277fn merged_targets(scan: &ScanResult, discovery: &[TargetStats]) -> Vec<String> {
280 let mut set: BTreeSet<String> = scan.targets.keys().cloned().collect();
281 for stats in discovery {
282 set.insert(stats.target.clone());
283 }
284 set.into_iter().collect()
285}
286
287pub fn render_keel_toml(
290 scan: &ScanResult,
291 discovery: &[TargetStats],
292 stamp: Option<&str>,
293) -> String {
294 let by_target: BTreeMap<&str, &TargetStats> =
295 discovery.iter().map(|s| (s.target.as_str(), s)).collect();
296 let observed_runs = u32::from(!discovery.is_empty());
297
298 let mut out = String::new();
299 let date = stamp.map_or_else(String::new, |d| format!(" ({d})"));
300 let header = format!(
301 "# Generated by keel init from {} static scan{} + {} observed run{}{}\n",
302 scan.files_scanned,
303 plural(scan.files_scanned),
304 observed_runs,
305 plural(observed_runs as usize),
306 date,
307 );
308 out.push_str(&header);
309 out.push_str(
310 "# Every entry below was found in YOUR code. Delete anything; defaults still apply.\n",
311 );
312
313 for target in merged_targets(scan, discovery) {
314 out.push('\n');
315 let evidence = scan.targets.get(&target);
316 let stats = by_target.get(target.as_str()).copied();
317 out.push_str(&render_target_block(&target, evidence, stats));
318 }
319 out
320}
321
322fn render_target_block(
327 target: &str,
328 evidence: Option<&TargetEvidence>,
329 stats: Option<&TargetStats>,
330) -> String {
331 let mut buf = String::new();
332 let out = &mut buf;
333 let header = format!("[target.\"{target}\"]");
334 let seen_comment = evidence.map(|e| {
335 let labels = e
336 .sightings
337 .iter()
338 .map(scan::Sighting::label)
339 .collect::<Vec<_>>()
340 .join(", ");
341 format!("# seen in: {labels}")
342 });
343 let comment =
344 seen_comment.unwrap_or_else(|| "# seen only at runtime (.keel/discovery.db)".to_owned());
345 out.push_str(&pad_comment(&header, &comment));
346 out.push('\n');
347
348 if let Some(s) = stats {
349 let observed = format!("# {}\n", observed_comment(s));
350 out.push_str(&observed);
351 }
352
353 let class = evidence.map_or_else(
354 || {
355 if target.starts_with("llm:") {
356 TargetClass::Llm
357 } else {
358 TargetClass::Host
359 }
360 },
361 |e| e.class,
362 );
363 match (class, stats) {
364 (TargetClass::Llm, Some(s)) => {
367 out.push_str(LLM_BODY_HEAD);
368 out.push_str(&observed_rate_line(s));
369 out.push('\n');
370 out.push_str(LLM_CACHE_LINE);
371 }
372 _ => out.push_str(&policy_body(class)),
377 }
378 buf
379}
380
381const HOST_BODY: &str = concat!(
384 "timeout = \"30s\"\n",
385 "retry = { attempts = 3, schedule = \"exp(200ms, x2, max 30s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
386 "breaker = { failures = 5, cooldown = \"15s\" }\n",
387);
388
389const LLM_BODY_HEAD: &str = concat!(
393 "timeout = \"120s\"\n",
394 "retry = { attempts = 6, schedule = \"exp(500ms, x2, max 60s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
395 "breaker = { failures = 5, cooldown = \"30s\" }\n",
396);
397
398const LLM_CACHE_LINE: &str =
400 "cache = { mode = \"dev\" } # dev-loop cache; disabled when KEEL_ENV=prod\n";
401
402const LLM_RATE_FLOOR_PER_MIN: u64 = 60;
407
408const LLM_RATE_HEADROOM: u64 = 3;
413
414fn policy_body(class: TargetClass) -> String {
420 match class {
421 TargetClass::Host => HOST_BODY.to_owned(),
422 TargetClass::Llm => format!("{LLM_BODY_HEAD}{LLM_CACHE_LINE}"),
423 }
424}
425
426fn mean_per_min_floor(s: &TargetStats) -> u64 {
431 let span_ms = u64::try_from((s.last_seen_ms - s.first_seen_ms).max(0)).unwrap_or(u64::MAX);
432 if span_ms == 0 {
433 return 0;
434 }
435 let calls = u64::try_from(s.calls.max(0)).unwrap_or(u64::MAX);
436 calls.saturating_mul(60_000) / span_ms
437}
438
439fn llm_rate_per_min(s: &TargetStats) -> u64 {
444 let mean = mean_per_min_floor(s);
445 if mean == 0 {
446 return LLM_RATE_FLOOR_PER_MIN;
447 }
448 round_up_clean(mean.saturating_mul(LLM_RATE_HEADROOM)).max(LLM_RATE_FLOOR_PER_MIN)
449}
450
451fn round_up_clean(n: u64) -> u64 {
455 if n == 0 {
456 return 0;
457 }
458 let mut unit = 1_u64;
459 loop {
460 for m in [1_u64, 2, 5] {
461 let candidate = m.saturating_mul(unit);
462 if candidate >= n {
463 return candidate;
464 }
465 }
466 match unit.checked_mul(10) {
467 Some(next) => unit = next,
468 None => return u64::MAX,
469 }
470 }
471}
472
473fn observed_rate_line(s: &TargetStats) -> String {
477 let mean = mean_per_min_floor(s);
478 let comment = if mean == 0 {
479 "# floor: single observation window, no mean to derive".to_owned()
480 } else {
481 format!("# headroom over your observed mean of ~{mean}/min")
482 };
483 pad_comment(
484 &format!("rate = \"{}/min\"", llm_rate_per_min(s)),
485 &comment,
486 )
487}
488
489fn observed_comment(s: &TargetStats) -> String {
491 format!(
492 "observed: {} call{}, {} retr{}, ~{:.1}/min mean (.keel/discovery.db)",
493 s.calls,
494 plural(usize::try_from(s.calls).unwrap_or(usize::MAX)),
495 s.retries,
496 if s.retries == 1 { "y" } else { "ies" },
497 per_minute(s),
498 )
499}
500
501fn per_minute(s: &TargetStats) -> f64 {
504 #[expect(
505 clippy::cast_precision_loss,
506 reason = "call counts and ms spans are small; f64 is exact enough for a comment"
507 )]
508 let (calls, span_ms) = (
509 s.calls as f64,
510 (s.last_seen_ms - s.first_seen_ms).max(0) as f64,
511 );
512 if span_ms <= 0.0 {
513 calls
514 } else {
515 calls * 60_000.0 / span_ms
516 }
517}
518
519fn pad_comment(line: &str, comment: &str) -> String {
522 let width = if line.len() < COMMENT_COL {
523 COMMENT_COL
524 } else {
525 line.len() + 1
526 };
527 format!("{line:<width$}{comment}")
528}
529
530fn diff(
538 toml_path: &Path,
539 scan: &ScanResult,
540 discovery: &[TargetStats],
541 generated: &[String],
542 content: &str,
543) -> Rendered {
544 let existing_text = match read_existing(toml_path) {
545 Ok(t) => t,
546 Err(e) => return config_error(&e),
547 };
548 let existing = match existing_text
549 .as_deref()
550 .map(|text| existing_targets(text, toml_path))
551 .transpose()
552 {
553 Ok(set) => set.unwrap_or_default(),
554 Err(e) => return config_error(&e),
555 };
556 let generated_set: BTreeSet<&str> = generated.iter().map(String::as_str).collect();
557 let added: Vec<String> = generated_set
558 .iter()
559 .filter(|t| !existing.contains(**t))
560 .map(|t| (*t).to_owned())
561 .collect();
562 let removed: Vec<String> = existing
563 .iter()
564 .filter(|t| !generated_set.contains(t.as_str()))
565 .cloned()
566 .collect();
567 let unchanged: Vec<String> = generated_set
568 .iter()
569 .filter(|t| existing.contains(**t))
570 .map(|t| (*t).to_owned())
571 .collect();
572
573 let ops = if existing_text.is_none() {
574 vec![PolicyOp::AppendBlock {
577 text: content.to_owned(),
578 }]
579 } else {
580 let by_target: BTreeMap<&str, &TargetStats> =
581 discovery.iter().map(|s| (s.target.as_str(), s)).collect();
582 let mut ops: Vec<PolicyOp> = removed
583 .iter()
584 .map(|t| PolicyOp::Remove {
585 path: PolicyPath::new(["target", t.as_str()]),
586 })
587 .collect();
588 ops.extend(added.iter().map(|t| PolicyOp::AppendBlock {
589 text: render_target_block(t, scan.targets.get(t), by_target.get(t.as_str()).copied()),
590 }));
591 ops
592 };
593 let proposal = match propose(existing_text.as_deref(), &ops) {
594 Ok(p) => p,
595 Err(e) => return config_error(&e.to_string()),
596 };
597
598 let mut human = String::from("keel \u{25b8} keel init --diff\n");
599 if added.is_empty() && removed.is_empty() {
600 human.push_str(" no changes: every discovered target is already in keel.toml.\n");
601 } else {
602 for t in &added {
603 let line = format!(" + [target.\"{t}\"] (found in code, not in keel.toml)\n");
604 human.push_str(&line);
605 }
606 for t in &removed {
607 let line = format!(" - [target.\"{t}\"] (in keel.toml, no longer found in code)\n");
608 human.push_str(&line);
609 }
610 }
611 if !proposal.patch.is_empty() {
612 human.push_str("\napply with `git apply` (or `patch -p1`):\n\n");
613 human.push_str(&proposal.patch);
614 }
615 let report = DiffReport {
616 added,
617 changes: proposal.changes,
618 patch: proposal.patch,
619 removed,
620 unchanged,
621 };
622 Rendered::ok(human, to_json(&report))
623}
624
625fn read_existing(toml_path: &Path) -> Result<Option<String>, String> {
628 if !toml_path.exists() {
629 return Ok(None);
630 }
631 std::fs::read_to_string(toml_path)
632 .map(Some)
633 .map_err(|e| format!("could not read {}: {e}", toml_path.display()))
634}
635
636fn existing_targets(text: &str, toml_path: &Path) -> Result<BTreeSet<String>, String> {
638 let value: toml::Value = text
639 .parse()
640 .map_err(|e| format!("{} is not valid TOML: {e}", toml_path.display()))?;
641 let mut set = BTreeSet::new();
642 if let Some(table) = value.get("target").and_then(toml::Value::as_table) {
643 for key in table.keys() {
644 set.insert(key.clone());
645 }
646 }
647 Ok(set)
648}
649
650fn update_gitignore(project: &Path) -> std::io::Result<bool> {
653 let path = project.join(".gitignore");
654 if !path.exists() {
655 std::fs::write(&path, ".keel/\n")?;
656 return Ok(true);
657 }
658 let text = std::fs::read_to_string(&path)?;
659 let ignored = text
660 .lines()
661 .map(str::trim)
662 .any(|l| l == ".keel" || l == ".keel/");
663 if ignored {
664 return Ok(false);
665 }
666 let mut updated = text;
667 if !updated.ends_with('\n') && !updated.is_empty() {
668 updated.push('\n');
669 }
670 updated.push_str(".keel/\n");
671 std::fs::write(&path, updated)?;
672 Ok(true)
673}
674
675pub(crate) fn has_python_files(project: &Path) -> bool {
679 let mut found = Vec::new();
680 scan::collect_files(project, &["py"], &mut found);
681 !found.is_empty()
682}
683
684fn config_error(message: &str) -> Rendered {
686 #[derive(Serialize)]
687 struct ErrReport<'a> {
688 code: &'static str,
689 error: &'a str,
690 }
691 let human = format!("keel \u{25b8} KEEL-E001: {message}");
692 Rendered {
693 human,
694 json: to_json(&ErrReport {
695 code: "KEEL-E001",
696 error: message,
697 }),
698 exit: EXIT_USAGE,
699 to_stderr: true,
700 }
701 .with_exit(EXIT_USAGE)
702}
703
704pub(crate) fn plural(n: usize) -> &'static str {
706 if n == 1 { "" } else { "s" }
707}
708
709fn today_utc() -> String {
713 let secs = std::time::SystemTime::now()
714 .duration_since(std::time::UNIX_EPOCH)
715 .map_or(0, |d| d.as_secs());
716 let days = i64::try_from(secs / 86_400).unwrap_or(0);
717 let (y, m, d) = civil_from_days(days);
718 format!("{y:04}-{m:02}-{d:02}")
719}
720
721fn civil_from_days(z: i64) -> (i64, u32, u32) {
723 let z = z + 719_468;
724 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
725 let doe = z - era * 146_097;
726 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
727 let y = yoe + era * 400;
728 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
729 let mp = (5 * doy + 2) / 153;
730 let d = doy - (153 * mp + 2) / 5 + 1;
731 let m = if mp < 10 { mp + 3 } else { mp - 9 };
732 let y = if m <= 2 { y + 1 } else { y };
733 #[expect(
734 clippy::cast_sign_loss,
735 clippy::cast_possible_truncation,
736 reason = "m,d in 1..=31"
737 )]
738 (y, m as u32, d as u32)
739}
740
741#[cfg(test)]
742mod tests {
743 use std::fs;
744
745 use keel_journal::ErrorClass;
746 use tempfile::TempDir;
747
748 use super::*;
749
750 fn llm_stats(calls: i64, first_seen_ms: i64, last_seen_ms: i64) -> TargetStats {
753 TargetStats {
754 target: "llm:openai".to_owned(),
755 calls,
756 attempts: calls,
757 retries: 0,
758 successes: calls,
759 failures: 0,
760 cache_hits: 0,
761 throttled: 0,
762 breaker_opens: 0,
763 total_latency_ms: 0,
764 max_latency_ms: 0,
765 first_seen_ms,
766 last_seen_ms,
767 last_error_class: None,
768 last_error_status: None,
769 not_retried: 0,
770 unwrapped_calls: 0,
771 }
772 }
773
774 fn host_scan() -> ScanResult {
775 let mut s = ScanResult {
776 files_scanned: 2,
777 python_available: true,
778 ..ScanResult::default()
779 };
780 s.targets.insert(
782 "api.example.com".to_owned(),
783 TargetEvidence {
784 class: TargetClass::Host,
785 sightings: [scan::Sighting {
786 file: "app.py".into(),
787 line: 4,
788 }]
789 .into_iter()
790 .collect(),
791 },
792 );
793 s.targets.insert(
794 "llm:openai".to_owned(),
795 TargetEvidence {
796 class: TargetClass::Llm,
797 sightings: [scan::Sighting {
798 file: "app.py".into(),
799 line: 2,
800 }]
801 .into_iter()
802 .collect(),
803 },
804 );
805 s
806 }
807
808 #[test]
809 fn header_counts_and_no_date_by_default() {
810 let out = render_keel_toml(&host_scan(), &[], None);
811 assert!(
812 out.starts_with("# Generated by keel init from 2 static scans + 0 observed runs\n")
813 );
814 assert!(!out.contains("202"), "no date without --stamp");
815 }
816
817 #[test]
818 fn stamp_adds_a_date() {
819 let out = render_keel_toml(&host_scan(), &[], Some("2026-07-12"));
820 assert!(out.lines().next().unwrap().ends_with(" (2026-07-12)"));
821 }
822
823 #[test]
824 fn host_and_llm_blocks_cite_evidence() {
825 let out = render_keel_toml(&host_scan(), &[], None);
826 assert!(out.contains("[target.\"api.example.com\"]"));
827 assert!(out.contains("# seen in: app.py:4"));
828 assert!(out.contains("[target.\"llm:openai\"]"));
829 assert!(out.contains("# seen in: app.py:2"));
830 assert!(out.contains("cache = { mode = \"dev\" }"));
831 }
832
833 #[test]
834 fn discovery_only_target_is_surfaced_with_observed_comment() {
835 let scan = ScanResult {
836 files_scanned: 1,
837 python_available: true,
838 ..ScanResult::default()
839 };
840 let stats = TargetStats {
841 target: "api.dynamic.com".to_owned(),
842 calls: 120,
843 attempts: 132,
844 retries: 12,
845 successes: 120,
846 failures: 0,
847 cache_hits: 0,
848 throttled: 0,
849 breaker_opens: 0,
850 total_latency_ms: 12_000,
851 max_latency_ms: 300,
852 first_seen_ms: 0,
853 last_seen_ms: 120_000, last_error_class: None,
855 last_error_status: None,
856 not_retried: 0,
857 unwrapped_calls: 0,
858 };
859 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
860 assert!(out.contains("[target.\"api.dynamic.com\"]"));
861 assert!(out.contains("# seen only at runtime (.keel/discovery.db)"));
862 assert!(out.contains("# observed: 120 calls, 12 retries, ~60.0/min mean"));
863 assert!(out.contains("+ 1 observed run\n"));
865 }
866
867 #[test]
868 fn error_class_import_is_available() {
869 let _ = ErrorClass::Http;
871 }
872
873 #[test]
874 fn default_body_matches_the_frozen_pack() {
875 let defaults: toml::Value = include_str!("../contract/defaults.toml")
877 .parse()
878 .expect("defaults.toml parses");
879 let outbound = &defaults["defaults"]["outbound"];
880 assert_eq!(outbound["timeout"].as_str(), Some("30s"));
881 assert_eq!(outbound["retry"]["attempts"].as_integer(), Some(3));
882 assert_eq!(outbound["breaker"]["cooldown"].as_str(), Some("15s"));
883 let llm = &defaults["defaults"]["llm"];
884 assert_eq!(llm["timeout"].as_str(), Some("120s"));
885 assert_eq!(llm["retry"]["attempts"].as_integer(), Some(6));
886 assert_eq!(llm["breaker"]["cooldown"].as_str(), Some("30s"));
887 assert_eq!(llm["cache"]["mode"].as_str(), Some("dev"));
888 assert!(policy_body(TargetClass::Host).contains("attempts = 3"));
890 assert!(policy_body(TargetClass::Host).contains("cooldown = \"15s\""));
891 assert!(policy_body(TargetClass::Llm).contains("attempts = 6"));
892 assert!(policy_body(TargetClass::Llm).contains("cooldown = \"30s\""));
893 assert!(policy_body(TargetClass::Llm).contains("mode = \"dev\""));
894 }
895
896 #[test]
897 fn civil_date_epoch_is_1970_01_01() {
898 assert_eq!(civil_from_days(0), (1970, 1, 1));
899 assert_eq!(civil_from_days(19_997), (2024, 10, 1));
900 }
901
902 #[test]
905 fn round_up_clean_walks_the_1_2_5_series() {
906 assert_eq!(round_up_clean(0), 0);
907 assert_eq!(round_up_clean(1), 1);
908 assert_eq!(round_up_clean(3), 5);
909 assert_eq!(round_up_clean(6), 10);
910 assert_eq!(round_up_clean(11), 20);
911 assert_eq!(round_up_clean(50), 50);
912 assert_eq!(round_up_clean(60), 100);
913 assert_eq!(round_up_clean(123), 200);
914 assert_eq!(round_up_clean(300), 500);
915 assert_eq!(round_up_clean(501), 1_000);
916 }
917
918 #[test]
919 fn llm_rate_is_mean_times_three_rounded_up_to_a_clean_value() {
920 let s = llm_stats(200, 0, 120_000);
922 assert_eq!(mean_per_min_floor(&s), 100);
923 assert_eq!(llm_rate_per_min(&s), 500);
924 }
925
926 #[test]
927 fn llm_rate_floors_at_60_for_sparse_traffic() {
928 let s = llm_stats(5, 0, 60_000);
930 assert_eq!(mean_per_min_floor(&s), 5);
931 assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
932 }
933
934 #[test]
935 fn llm_rate_zero_span_window_falls_back_to_floor() {
936 let s = llm_stats(500, 1_000, 1_000);
938 assert_eq!(mean_per_min_floor(&s), 0);
939 assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
940 }
941
942 #[test]
943 fn observed_llm_target_gets_an_active_rate_line() {
944 let scan = ScanResult {
945 files_scanned: 1,
946 python_available: true,
947 ..ScanResult::default()
948 };
949 let stats = llm_stats(200, 0, 120_000);
950 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
951
952 assert!(out.contains("[target.\"llm:openai\"]"));
953 assert!(out.contains("rate = \"500/min\""));
954 assert!(out.contains("# headroom over your observed mean of ~100/min"));
955 assert!(!out.contains("peak"));
957 let rate_at = out.find("rate =").expect("rate line present");
959 let cache_at = out.find("cache =").expect("cache line present");
960 assert!(rate_at < cache_at, "rate must precede cache");
961 }
962
963 #[test]
964 fn zero_span_llm_target_emits_floor_with_honest_comment() {
965 let scan = ScanResult {
966 files_scanned: 1,
967 python_available: true,
968 ..ScanResult::default()
969 };
970 let stats = llm_stats(9, 5_000, 5_000);
971 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
972 assert!(out.contains("rate = \"60/min\""));
973 assert!(out.contains("# floor: single observation window, no mean to derive"));
974 assert!(!out.contains("peak"));
975 }
976
977 #[test]
978 fn observed_host_target_stays_comments_only() {
979 let scan = ScanResult {
981 files_scanned: 1,
982 python_available: true,
983 ..ScanResult::default()
984 };
985 let stats = TargetStats {
986 target: "api.host.example".to_owned(),
987 ..llm_stats(200, 0, 120_000)
988 };
989 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
990 assert!(out.contains("[target.\"api.host.example\"]"));
991 assert!(
992 !out.contains("rate ="),
993 "host targets must not emit an active rate line"
994 );
995 }
996
997 #[test]
1000 fn refuses_when_keel_toml_already_exists() {
1001 let dir = TempDir::new().unwrap();
1002 fs::write(dir.path().join("keel.toml"), "# hand-written\n").unwrap();
1003
1004 let r = run(dir.path(), InitOptions::default());
1005
1006 assert_eq!(r.exit, EXIT_USAGE);
1007 assert!(r.to_stderr);
1008 assert!(r.human.contains("already exists"));
1009 assert_eq!(
1011 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1012 "# hand-written\n"
1013 );
1014 }
1015
1016 #[test]
1022 fn agents_cli_project_writes_keel_toml_into_the_agent_dir() {
1023 let dir = TempDir::new().unwrap();
1024 fs::create_dir(dir.path().join("app")).unwrap();
1025 fs::write(
1026 dir.path().join("agents-cli-manifest.yaml"),
1027 "schema_version: 1\nagent_directory: app\n",
1028 )
1029 .unwrap();
1030
1031 let r = run(dir.path(), InitOptions::default());
1032
1033 assert_eq!(r.exit, crate::EXIT_OK);
1034 assert!(!dir.path().join("keel.toml").exists(), "no root keel.toml");
1035 assert!(
1036 dir.path().join("app").join("keel.toml").exists(),
1037 "keel.toml lands in the agent directory"
1038 );
1039 assert_eq!(
1040 r.json["wrote"].as_str().unwrap(),
1041 dir.path()
1042 .join("app")
1043 .join("keel.toml")
1044 .display()
1045 .to_string()
1046 );
1047 }
1048
1049 #[test]
1053 fn non_agents_cli_project_writes_keel_toml_at_the_root() {
1054 let dir = TempDir::new().unwrap();
1055
1056 let r = run(dir.path(), InitOptions::default());
1057
1058 assert_eq!(r.exit, crate::EXIT_OK);
1059 assert!(dir.path().join("keel.toml").exists());
1060 }
1061
1062 #[test]
1066 fn agents_cli_refuses_when_the_redirected_path_already_exists() {
1067 let dir = TempDir::new().unwrap();
1068 fs::create_dir(dir.path().join("app")).unwrap();
1069 fs::write(
1070 dir.path().join("agents-cli-manifest.yaml"),
1071 "agent_directory: app\n",
1072 )
1073 .unwrap();
1074 fs::write(dir.path().join("app").join("keel.toml"), "# hand-written\n").unwrap();
1075
1076 let r = run(dir.path(), InitOptions::default());
1077
1078 assert_eq!(r.exit, EXIT_USAGE);
1079 assert!(r.human.contains("already exists"));
1080 assert_eq!(
1081 fs::read_to_string(dir.path().join("app").join("keel.toml")).unwrap(),
1082 "# hand-written\n"
1083 );
1084 }
1085
1086 #[test]
1088 fn agents_cli_toml_path_is_none_without_a_manifest() {
1089 let dir = TempDir::new().unwrap();
1090 assert!(agents_cli_toml_path(dir.path()).is_none());
1091 }
1092
1093 #[test]
1101 fn agents_cli_toml_path_is_none_when_agent_directory_escapes_the_project() {
1102 let root = TempDir::new().unwrap();
1103 let project = root.path().join("project");
1104 fs::create_dir(&project).unwrap();
1105 fs::create_dir(root.path().join("elsewhere")).unwrap();
1106 fs::write(
1107 project.join("agents-cli-manifest.yaml"),
1108 "agent_directory: ../elsewhere\n",
1109 )
1110 .unwrap();
1111
1112 assert!(agents_cli_toml_path(&project).is_none());
1113
1114 let r = run(&project, InitOptions::default());
1117 assert_eq!(r.exit, crate::EXIT_OK);
1118 assert!(project.join("keel.toml").exists());
1119 assert!(!root.path().join("elsewhere").join("keel.toml").exists());
1120 }
1121
1122 #[test]
1123 fn diff_reports_added_and_removed_targets_precisely() {
1124 let dir = TempDir::new().unwrap();
1125 fs::write(
1127 dir.path().join("app.mjs"),
1128 "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1129 )
1130 .unwrap();
1131 fs::write(
1133 dir.path().join("keel.toml"),
1134 "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n",
1135 )
1136 .unwrap();
1137
1138 let r = run(
1139 dir.path(),
1140 InitOptions {
1141 diff: true,
1142 stamp: false,
1143 agents: false,
1144 },
1145 );
1146
1147 assert_eq!(r.exit, crate::EXIT_OK);
1148 assert_eq!(
1149 r.json["added"].as_array().unwrap(),
1150 &vec![serde_json::json!("api.example.com")]
1151 );
1152 assert_eq!(
1153 r.json["removed"].as_array().unwrap(),
1154 &vec![serde_json::json!("api.gone.example")]
1155 );
1156 assert!(r.json["unchanged"].as_array().unwrap().is_empty());
1157 assert!(r.human.contains("+ [target.\"api.example.com\"]"));
1158 assert!(r.human.contains("- [target.\"api.gone.example\"]"));
1159 assert_eq!(
1161 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1162 "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n"
1163 );
1164 }
1165
1166 #[test]
1170 fn diff_emits_an_applyable_patch_and_structured_changes() {
1171 let dir = TempDir::new().unwrap();
1172 fs::write(
1173 dir.path().join("app.mjs"),
1174 "// 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",
1175 )
1176 .unwrap();
1177 let old = "\
1178# hand-tuned: keep this comment
1179
1180[target.\"api.example.com\"]
1181timeout = \"9s\" # user tuning survives
1182
1183[target.\"api.gone.example\"] # stale
1184timeout = \"5s\"
1185";
1186 fs::write(dir.path().join("keel.toml"), old).unwrap();
1187
1188 let r = run(
1189 dir.path(),
1190 InitOptions {
1191 diff: true,
1192 stamp: false,
1193 agents: false,
1194 },
1195 );
1196
1197 assert_eq!(r.exit, crate::EXIT_OK);
1198 let patch = r.json["patch"].as_str().unwrap();
1199 assert!(
1200 patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"),
1201 "{patch}"
1202 );
1203 assert!(r.human.contains("apply with `git apply`"));
1204 assert!(
1205 r.human.contains(patch),
1206 "the human output carries the patch verbatim"
1207 );
1208
1209 let applied = crate::diff::apply_unified(old, patch).unwrap();
1210 let value: toml::Value = applied.parse().expect("applied file parses");
1211 assert!(value["target"].get("api.gone.example").is_none());
1212 assert!(value["target"].get("api.new.example").is_some());
1213 assert!(applied.contains("# hand-tuned: keep this comment"));
1214 assert!(applied.contains("timeout = \"9s\" # user tuning survives"));
1215 assert!(applied.contains("[target.\"api.new.example\"]"));
1217 assert!(applied.contains("# seen in: app.mjs:3"));
1218
1219 let changes = r.json["changes"].as_array().unwrap();
1221 let paths: Vec<&str> = changes
1222 .iter()
1223 .map(|c| c["path"].as_str().unwrap())
1224 .collect();
1225 assert_eq!(
1226 paths,
1227 ["target.\"api.gone.example\"", "target.\"api.new.example\""]
1228 );
1229 assert!(changes[0]["after"].is_null());
1230 assert!(changes[1]["before"].is_null());
1231 assert_eq!(
1233 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1234 old
1235 );
1236 }
1237
1238 #[test]
1241 fn diff_without_existing_file_is_a_dev_null_creation_patch() {
1242 let dir = TempDir::new().unwrap();
1243 fs::write(
1244 dir.path().join("app.mjs"),
1245 "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1246 )
1247 .unwrap();
1248
1249 let r = run(
1250 dir.path(),
1251 InitOptions {
1252 diff: true,
1253 stamp: false,
1254 agents: false,
1255 },
1256 );
1257
1258 assert_eq!(r.exit, crate::EXIT_OK);
1259 let patch = r.json["patch"].as_str().unwrap();
1260 assert!(
1261 patch.starts_with("--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,"),
1262 "{patch}"
1263 );
1264 let scanned = scan::scan(dir.path());
1265 let expected = render_keel_toml(&scanned, &[], None);
1266 assert_eq!(crate::diff::apply_unified("", patch).unwrap(), expected);
1267 assert_eq!(
1268 r.json["added"].as_array().unwrap(),
1269 &vec![serde_json::json!("api.example.com")]
1270 );
1271 }
1272
1273 #[test]
1276 fn agents_creates_then_is_idempotent() {
1277 let dir = TempDir::new().unwrap();
1278 let opts = InitOptions {
1279 agents: true,
1280 ..InitOptions::default()
1281 };
1282 let r1 = run(dir.path(), opts);
1283 assert_eq!(r1.exit, crate::EXIT_OK);
1284 assert!(r1.json["wrote"].as_bool().unwrap());
1285 let path = dir.path().join("AGENTS.md");
1286 let c1 = fs::read_to_string(&path).unwrap();
1287 assert!(c1.contains("## Keel (resilience & durable execution)"));
1288 assert!(c1.contains("keel doctor --json"));
1289
1290 let r2 = run(dir.path(), opts);
1292 assert!(r2.json["already_current"].as_bool().unwrap());
1293 assert_eq!(fs::read_to_string(&path).unwrap(), c1);
1294 }
1295
1296 #[test]
1297 fn splice_appends_then_replaces_region_without_duplicating() {
1298 let block = agents_block();
1299 let (out, replaced, wrote) = splice_agents_block("# My project\n", &block);
1301 assert!(!replaced && wrote);
1302 assert!(out.starts_with("# My project\n\n"));
1303 assert!(out.contains(AGENTS_BEGIN) && out.contains(AGENTS_END));
1304 let (out2, replaced2, wrote2) = splice_agents_block(&out, &block);
1306 assert!(replaced2 && !wrote2);
1307 assert_eq!(out2, out);
1308 assert_eq!(
1309 out2.matches(AGENTS_BEGIN).count(),
1310 1,
1311 "exactly one Keel block"
1312 );
1313 }
1314
1315 #[test]
1316 fn gitignore_is_created_when_absent() {
1317 let dir = TempDir::new().unwrap();
1318 assert!(update_gitignore(dir.path()).unwrap());
1319 assert_eq!(
1320 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1321 ".keel/\n"
1322 );
1323 }
1324
1325 #[test]
1326 fn gitignore_is_appended_when_keel_line_missing() {
1327 let dir = TempDir::new().unwrap();
1328 fs::write(dir.path().join(".gitignore"), "node_modules/\n*.log").unwrap();
1330
1331 assert!(update_gitignore(dir.path()).unwrap());
1332
1333 assert_eq!(
1334 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1335 "node_modules/\n*.log\n.keel/\n"
1336 );
1337 }
1338
1339 #[test]
1340 fn gitignore_is_a_noop_when_already_ignored() {
1341 let dir = TempDir::new().unwrap();
1342 let original = "build/\n.keel/\ncoverage/\n";
1343 fs::write(dir.path().join(".gitignore"), original).unwrap();
1344
1345 assert!(!update_gitignore(dir.path()).unwrap());
1346
1347 assert_eq!(
1348 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1349 original
1350 );
1351 }
1352
1353 #[test]
1362 fn agents_cli_toml_path_rejects_absolute_path_agent_directory() {
1363 let root = TempDir::new().unwrap();
1364 let project = root.path().join("project");
1365 fs::create_dir(&project).unwrap();
1366 let outside = root.path().join("outside");
1367 fs::create_dir(&outside).unwrap();
1368
1369 fs::write(
1370 project.join("agents-cli-manifest.yaml"),
1371 format!("agent_directory: {}\n", outside.display()),
1374 )
1375 .unwrap();
1376
1377 assert!(agents_cli_toml_path(&project).is_none());
1380
1381 let r = run(&project, InitOptions::default());
1383 assert_eq!(r.exit, crate::EXIT_OK);
1384 assert!(project.join("keel.toml").exists());
1385 assert!(!outside.join("keel.toml").exists());
1386 }
1387
1388 #[cfg(unix)]
1396 #[test]
1397 fn agents_cli_toml_path_rejects_symlink_escape_to_outside_project() {
1398 use std::os::unix::fs as unix_fs;
1399
1400 let root = TempDir::new().unwrap();
1401 let project = root.path().join("project");
1402 fs::create_dir(&project).unwrap();
1403 let outside = root.path().join("outside");
1404 fs::create_dir(&outside).unwrap();
1405
1406 let symlink = project.join("app");
1408 unix_fs::symlink(&outside, &symlink).unwrap();
1409
1410 fs::write(
1411 project.join("agents-cli-manifest.yaml"),
1412 "agent_directory: app\n",
1413 )
1414 .unwrap();
1415
1416 assert!(agents_cli_toml_path(&project).is_none());
1419
1420 let r = run(&project, InitOptions::default());
1422 assert_eq!(r.exit, crate::EXIT_OK);
1423 assert!(project.join("keel.toml").exists());
1424 assert!(!outside.join("keel.toml").exists());
1425 }
1426}