1use std::collections::{BTreeMap, BTreeSet};
15use std::path::Path;
16
17use keel_journal::TargetStats;
18use serde::Serialize;
19
20use crate::diff::{ChangeHunk, PolicyOp, PolicyPath, propose};
21use crate::render::to_json;
22use crate::scan::{ScanResult, TargetClass, TargetEvidence};
23use crate::{EXIT_USAGE, Rendered, evidence, scan};
24
25const COMMENT_COL: usize = 37;
27
28#[derive(Debug, Clone, Copy, Default)]
30pub struct InitOptions {
31 pub diff: bool,
33 pub stamp: bool,
35 pub agents: bool,
38}
39
40const AGENTS_BEGIN: &str = "<!-- keel:begin -->";
43const AGENTS_END: &str = "<!-- keel:end -->";
44
45const AGENTS_SNIPPET: &str = include_str!("../templates/agents-snippet.md");
52
53#[must_use]
56pub fn agents_block() -> String {
57 format!("{AGENTS_BEGIN}\n{AGENTS_SNIPPET}\n{AGENTS_END}\n")
58}
59
60#[derive(Debug, Serialize)]
62struct AgentsReport {
63 already_current: bool,
64 path: String,
65 updated: bool,
66 wrote: bool,
67}
68
69fn run_agents(project: &Path) -> Rendered {
73 let path = project.join("AGENTS.md");
74 let existing = match std::fs::read_to_string(&path) {
75 Ok(text) => text,
76 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
77 Err(e) => return config_error(&format!("could not read {}: {e}", path.display())),
78 };
79 let block = agents_block();
80 let (new_content, replaced, wrote) = splice_agents_block(&existing, &block);
81 let already_current = !wrote;
82 let updated = wrote && replaced;
85 if wrote && let Err(e) = std::fs::write(&path, &new_content) {
86 return config_error(&format!("could not write {}: {e}", path.display()));
87 }
88 let verb = if already_current {
89 "already current"
90 } else if updated {
91 "updated the Keel section in"
92 } else {
93 "wrote the Keel section to"
94 };
95 let human = format!("keel \u{25b8} {verb} {}", path.display());
96 let report = AgentsReport {
97 already_current,
98 path: path.display().to_string(),
99 updated,
100 wrote,
101 };
102 Rendered::ok(human, to_json(&report))
103}
104
105fn splice_agents_block(existing: &str, block: &str) -> (String, bool, bool) {
109 if let (Some(start), Some(end_idx)) = (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
110 let end = end_idx + AGENTS_END.len();
111 let tail_start = existing[end..].strip_prefix('\n').map_or(end, |_| end + 1);
114 let mut out = String::with_capacity(existing.len());
115 out.push_str(&existing[..start]);
116 out.push_str(block);
117 out.push_str(&existing[tail_start..]);
118 let needs_write = out != existing;
119 return (out, true, needs_write);
120 }
121 if existing.is_empty() {
122 return (block.to_owned(), false, true);
123 }
124 let mut out = existing.to_owned();
125 if !out.ends_with('\n') {
126 out.push('\n');
127 }
128 out.push('\n');
129 out.push_str(block);
130 (out, false, true)
131}
132
133#[derive(Debug, Serialize)]
135struct WroteReport {
136 gitignore_updated: bool,
137 observed_runs: u32,
138 static_scans: usize,
139 targets: Vec<String>,
140 wrote: String,
141}
142
143#[derive(Debug, Serialize)]
147struct DiffReport {
148 added: Vec<String>,
149 changes: Vec<ChangeHunk>,
150 patch: String,
151 removed: Vec<String>,
152 unchanged: Vec<String>,
153}
154
155pub fn run(project: &Path, opts: InitOptions) -> Rendered {
157 if opts.agents {
158 return run_agents(project);
159 }
160 let scan = scan::scan(project);
161 let discovery = match evidence::read_discovery(project) {
162 Ok(d) => d,
163 Err(e) => return config_error(&e),
164 };
165
166 let content = render_keel_toml(&scan, &discovery, opts.stamp.then(today_utc).as_deref());
167 let targets = merged_targets(&scan, &discovery);
168
169 let toml_path = evidence::keel_toml(project);
170 if opts.diff {
171 return diff(&toml_path, &scan, &discovery, &targets, &content);
172 }
173 if toml_path.exists() {
174 return config_error(&format!(
175 "{} already exists. Run `keel init --diff` to preview changes, or edit it directly.",
176 toml_path.display()
177 ));
178 }
179
180 if let Err(e) = std::fs::write(&toml_path, &content) {
181 return config_error(&format!("could not write {}: {e}", toml_path.display()));
182 }
183 let gitignore_updated = update_gitignore(project).unwrap_or(false);
184
185 let mut warnings = String::new();
186 if !scan.python_available && has_python_files(project) {
187 warnings.push_str(
188 "\nkeel \u{25b8} note: python3 was not found; Python files were not scanned.\n",
189 );
190 }
191
192 let observed_runs = u32::from(!discovery.is_empty());
193 let human = format!(
194 "keel \u{25b8} wrote {} ({} target{}) from {} static scan{} + {} observed run{}.{}",
195 toml_path.display(),
196 targets.len(),
197 plural(targets.len()),
198 scan.files_scanned,
199 plural(scan.files_scanned),
200 observed_runs,
201 plural(observed_runs as usize),
202 if warnings.is_empty() {
203 String::new()
204 } else {
205 warnings
206 }
207 );
208 let report = WroteReport {
209 gitignore_updated,
210 observed_runs,
211 static_scans: scan.files_scanned,
212 targets,
213 wrote: toml_path.display().to_string(),
214 };
215 Rendered::ok(human, to_json(&report))
216}
217
218fn merged_targets(scan: &ScanResult, discovery: &[TargetStats]) -> Vec<String> {
221 let mut set: BTreeSet<String> = scan.targets.keys().cloned().collect();
222 for stats in discovery {
223 set.insert(stats.target.clone());
224 }
225 set.into_iter().collect()
226}
227
228pub fn render_keel_toml(
231 scan: &ScanResult,
232 discovery: &[TargetStats],
233 stamp: Option<&str>,
234) -> String {
235 let by_target: BTreeMap<&str, &TargetStats> =
236 discovery.iter().map(|s| (s.target.as_str(), s)).collect();
237 let observed_runs = u32::from(!discovery.is_empty());
238
239 let mut out = String::new();
240 let date = stamp.map_or_else(String::new, |d| format!(" ({d})"));
241 let header = format!(
242 "# Generated by keel init from {} static scan{} + {} observed run{}{}\n",
243 scan.files_scanned,
244 plural(scan.files_scanned),
245 observed_runs,
246 plural(observed_runs as usize),
247 date,
248 );
249 out.push_str(&header);
250 out.push_str(
251 "# Every entry below was found in YOUR code. Delete anything; defaults still apply.\n",
252 );
253
254 for target in merged_targets(scan, discovery) {
255 out.push('\n');
256 let evidence = scan.targets.get(&target);
257 let stats = by_target.get(target.as_str()).copied();
258 out.push_str(&render_target_block(&target, evidence, stats));
259 }
260 out
261}
262
263fn render_target_block(
268 target: &str,
269 evidence: Option<&TargetEvidence>,
270 stats: Option<&TargetStats>,
271) -> String {
272 let mut buf = String::new();
273 let out = &mut buf;
274 let header = format!("[target.\"{target}\"]");
275 let seen_comment = evidence.map(|e| {
276 let labels = e
277 .sightings
278 .iter()
279 .map(scan::Sighting::label)
280 .collect::<Vec<_>>()
281 .join(", ");
282 format!("# seen in: {labels}")
283 });
284 let comment =
285 seen_comment.unwrap_or_else(|| "# seen only at runtime (.keel/discovery.db)".to_owned());
286 out.push_str(&pad_comment(&header, &comment));
287 out.push('\n');
288
289 if let Some(s) = stats {
290 let observed = format!("# {}\n", observed_comment(s));
291 out.push_str(&observed);
292 }
293
294 let class = evidence.map_or_else(
295 || {
296 if target.starts_with("llm:") {
297 TargetClass::Llm
298 } else {
299 TargetClass::Host
300 }
301 },
302 |e| e.class,
303 );
304 match (class, stats) {
305 (TargetClass::Llm, Some(s)) => {
308 out.push_str(LLM_BODY_HEAD);
309 out.push_str(&observed_rate_line(s));
310 out.push('\n');
311 out.push_str(LLM_CACHE_LINE);
312 }
313 _ => out.push_str(&policy_body(class)),
318 }
319 buf
320}
321
322const HOST_BODY: &str = concat!(
325 "timeout = \"30s\"\n",
326 "retry = { attempts = 3, schedule = \"exp(200ms, x2, max 30s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
327 "breaker = { failures = 5, cooldown = \"15s\" }\n",
328);
329
330const LLM_BODY_HEAD: &str = concat!(
334 "timeout = \"120s\"\n",
335 "retry = { attempts = 6, schedule = \"exp(500ms, x2, max 60s, jitter)\", on = [\"conn\", \"timeout\", \"429\", \"5xx\"] }\n",
336 "breaker = { failures = 5, cooldown = \"30s\" }\n",
337);
338
339const LLM_CACHE_LINE: &str =
341 "cache = { mode = \"dev\" } # dev-loop cache; disabled when KEEL_ENV=prod\n";
342
343const LLM_RATE_FLOOR_PER_MIN: u64 = 60;
348
349const LLM_RATE_HEADROOM: u64 = 3;
354
355fn policy_body(class: TargetClass) -> String {
361 match class {
362 TargetClass::Host => HOST_BODY.to_owned(),
363 TargetClass::Llm => format!("{LLM_BODY_HEAD}{LLM_CACHE_LINE}"),
364 }
365}
366
367fn mean_per_min_floor(s: &TargetStats) -> u64 {
372 let span_ms = u64::try_from((s.last_seen_ms - s.first_seen_ms).max(0)).unwrap_or(u64::MAX);
373 if span_ms == 0 {
374 return 0;
375 }
376 let calls = u64::try_from(s.calls.max(0)).unwrap_or(u64::MAX);
377 calls.saturating_mul(60_000) / span_ms
378}
379
380fn llm_rate_per_min(s: &TargetStats) -> u64 {
385 let mean = mean_per_min_floor(s);
386 if mean == 0 {
387 return LLM_RATE_FLOOR_PER_MIN;
388 }
389 round_up_clean(mean.saturating_mul(LLM_RATE_HEADROOM)).max(LLM_RATE_FLOOR_PER_MIN)
390}
391
392fn round_up_clean(n: u64) -> u64 {
396 if n == 0 {
397 return 0;
398 }
399 let mut unit = 1_u64;
400 loop {
401 for m in [1_u64, 2, 5] {
402 let candidate = m.saturating_mul(unit);
403 if candidate >= n {
404 return candidate;
405 }
406 }
407 match unit.checked_mul(10) {
408 Some(next) => unit = next,
409 None => return u64::MAX,
410 }
411 }
412}
413
414fn observed_rate_line(s: &TargetStats) -> String {
418 let mean = mean_per_min_floor(s);
419 let comment = if mean == 0 {
420 "# floor: single observation window, no mean to derive".to_owned()
421 } else {
422 format!("# headroom over your observed mean of ~{mean}/min")
423 };
424 pad_comment(
425 &format!("rate = \"{}/min\"", llm_rate_per_min(s)),
426 &comment,
427 )
428}
429
430fn observed_comment(s: &TargetStats) -> String {
432 format!(
433 "observed: {} call{}, {} retr{}, ~{:.1}/min mean (.keel/discovery.db)",
434 s.calls,
435 plural(usize::try_from(s.calls).unwrap_or(usize::MAX)),
436 s.retries,
437 if s.retries == 1 { "y" } else { "ies" },
438 per_minute(s),
439 )
440}
441
442fn per_minute(s: &TargetStats) -> f64 {
445 #[expect(
446 clippy::cast_precision_loss,
447 reason = "call counts and ms spans are small; f64 is exact enough for a comment"
448 )]
449 let (calls, span_ms) = (
450 s.calls as f64,
451 (s.last_seen_ms - s.first_seen_ms).max(0) as f64,
452 );
453 if span_ms <= 0.0 {
454 calls
455 } else {
456 calls * 60_000.0 / span_ms
457 }
458}
459
460fn pad_comment(line: &str, comment: &str) -> String {
463 let width = if line.len() < COMMENT_COL {
464 COMMENT_COL
465 } else {
466 line.len() + 1
467 };
468 format!("{line:<width$}{comment}")
469}
470
471fn diff(
479 toml_path: &Path,
480 scan: &ScanResult,
481 discovery: &[TargetStats],
482 generated: &[String],
483 content: &str,
484) -> Rendered {
485 let existing_text = match read_existing(toml_path) {
486 Ok(t) => t,
487 Err(e) => return config_error(&e),
488 };
489 let existing = match existing_text
490 .as_deref()
491 .map(|text| existing_targets(text, toml_path))
492 .transpose()
493 {
494 Ok(set) => set.unwrap_or_default(),
495 Err(e) => return config_error(&e),
496 };
497 let generated_set: BTreeSet<&str> = generated.iter().map(String::as_str).collect();
498 let added: Vec<String> = generated_set
499 .iter()
500 .filter(|t| !existing.contains(**t))
501 .map(|t| (*t).to_owned())
502 .collect();
503 let removed: Vec<String> = existing
504 .iter()
505 .filter(|t| !generated_set.contains(t.as_str()))
506 .cloned()
507 .collect();
508 let unchanged: Vec<String> = generated_set
509 .iter()
510 .filter(|t| existing.contains(**t))
511 .map(|t| (*t).to_owned())
512 .collect();
513
514 let ops = if existing_text.is_none() {
515 vec![PolicyOp::AppendBlock {
518 text: content.to_owned(),
519 }]
520 } else {
521 let by_target: BTreeMap<&str, &TargetStats> =
522 discovery.iter().map(|s| (s.target.as_str(), s)).collect();
523 let mut ops: Vec<PolicyOp> = removed
524 .iter()
525 .map(|t| PolicyOp::Remove {
526 path: PolicyPath::new(["target", t.as_str()]),
527 })
528 .collect();
529 ops.extend(added.iter().map(|t| PolicyOp::AppendBlock {
530 text: render_target_block(t, scan.targets.get(t), by_target.get(t.as_str()).copied()),
531 }));
532 ops
533 };
534 let proposal = match propose(existing_text.as_deref(), &ops) {
535 Ok(p) => p,
536 Err(e) => return config_error(&e.to_string()),
537 };
538
539 let mut human = String::from("keel \u{25b8} keel init --diff\n");
540 if added.is_empty() && removed.is_empty() {
541 human.push_str(" no changes: every discovered target is already in keel.toml.\n");
542 } else {
543 for t in &added {
544 let line = format!(" + [target.\"{t}\"] (found in code, not in keel.toml)\n");
545 human.push_str(&line);
546 }
547 for t in &removed {
548 let line = format!(" - [target.\"{t}\"] (in keel.toml, no longer found in code)\n");
549 human.push_str(&line);
550 }
551 }
552 if !proposal.patch.is_empty() {
553 human.push_str("\napply with `git apply` (or `patch -p1`):\n\n");
554 human.push_str(&proposal.patch);
555 }
556 let report = DiffReport {
557 added,
558 changes: proposal.changes,
559 patch: proposal.patch,
560 removed,
561 unchanged,
562 };
563 Rendered::ok(human, to_json(&report))
564}
565
566fn read_existing(toml_path: &Path) -> Result<Option<String>, String> {
569 if !toml_path.exists() {
570 return Ok(None);
571 }
572 std::fs::read_to_string(toml_path)
573 .map(Some)
574 .map_err(|e| format!("could not read {}: {e}", toml_path.display()))
575}
576
577fn existing_targets(text: &str, toml_path: &Path) -> Result<BTreeSet<String>, String> {
579 let value: toml::Value = text
580 .parse()
581 .map_err(|e| format!("{} is not valid TOML: {e}", toml_path.display()))?;
582 let mut set = BTreeSet::new();
583 if let Some(table) = value.get("target").and_then(toml::Value::as_table) {
584 for key in table.keys() {
585 set.insert(key.clone());
586 }
587 }
588 Ok(set)
589}
590
591fn update_gitignore(project: &Path) -> std::io::Result<bool> {
594 let path = project.join(".gitignore");
595 if !path.exists() {
596 std::fs::write(&path, ".keel/\n")?;
597 return Ok(true);
598 }
599 let text = std::fs::read_to_string(&path)?;
600 let ignored = text
601 .lines()
602 .map(str::trim)
603 .any(|l| l == ".keel" || l == ".keel/");
604 if ignored {
605 return Ok(false);
606 }
607 let mut updated = text;
608 if !updated.ends_with('\n') && !updated.is_empty() {
609 updated.push('\n');
610 }
611 updated.push_str(".keel/\n");
612 std::fs::write(&path, updated)?;
613 Ok(true)
614}
615
616pub(crate) fn has_python_files(project: &Path) -> bool {
620 let mut found = Vec::new();
621 scan::collect_files(project, &["py"], &mut found);
622 !found.is_empty()
623}
624
625fn config_error(message: &str) -> Rendered {
627 #[derive(Serialize)]
628 struct ErrReport<'a> {
629 code: &'static str,
630 error: &'a str,
631 }
632 let human = format!("keel \u{25b8} KEEL-E001: {message}");
633 Rendered {
634 human,
635 json: to_json(&ErrReport {
636 code: "KEEL-E001",
637 error: message,
638 }),
639 exit: EXIT_USAGE,
640 to_stderr: true,
641 }
642 .with_exit(EXIT_USAGE)
643}
644
645pub(crate) fn plural(n: usize) -> &'static str {
647 if n == 1 { "" } else { "s" }
648}
649
650fn today_utc() -> String {
654 let secs = std::time::SystemTime::now()
655 .duration_since(std::time::UNIX_EPOCH)
656 .map_or(0, |d| d.as_secs());
657 let days = i64::try_from(secs / 86_400).unwrap_or(0);
658 let (y, m, d) = civil_from_days(days);
659 format!("{y:04}-{m:02}-{d:02}")
660}
661
662fn civil_from_days(z: i64) -> (i64, u32, u32) {
664 let z = z + 719_468;
665 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
666 let doe = z - era * 146_097;
667 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
668 let y = yoe + era * 400;
669 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
670 let mp = (5 * doy + 2) / 153;
671 let d = doy - (153 * mp + 2) / 5 + 1;
672 let m = if mp < 10 { mp + 3 } else { mp - 9 };
673 let y = if m <= 2 { y + 1 } else { y };
674 #[expect(
675 clippy::cast_sign_loss,
676 clippy::cast_possible_truncation,
677 reason = "m,d in 1..=31"
678 )]
679 (y, m as u32, d as u32)
680}
681
682#[cfg(test)]
683mod tests {
684 use std::fs;
685
686 use keel_journal::ErrorClass;
687 use tempfile::TempDir;
688
689 use super::*;
690
691 fn llm_stats(calls: i64, first_seen_ms: i64, last_seen_ms: i64) -> TargetStats {
694 TargetStats {
695 target: "llm:openai".to_owned(),
696 calls,
697 attempts: calls,
698 retries: 0,
699 successes: calls,
700 failures: 0,
701 cache_hits: 0,
702 throttled: 0,
703 breaker_opens: 0,
704 total_latency_ms: 0,
705 max_latency_ms: 0,
706 first_seen_ms,
707 last_seen_ms,
708 last_error_class: None,
709 last_error_status: None,
710 not_retried: 0,
711 unwrapped_calls: 0,
712 }
713 }
714
715 fn host_scan() -> ScanResult {
716 let mut s = ScanResult {
717 files_scanned: 2,
718 python_available: true,
719 ..ScanResult::default()
720 };
721 s.targets.insert(
723 "api.example.com".to_owned(),
724 TargetEvidence {
725 class: TargetClass::Host,
726 sightings: [scan::Sighting {
727 file: "app.py".into(),
728 line: 4,
729 }]
730 .into_iter()
731 .collect(),
732 },
733 );
734 s.targets.insert(
735 "llm:openai".to_owned(),
736 TargetEvidence {
737 class: TargetClass::Llm,
738 sightings: [scan::Sighting {
739 file: "app.py".into(),
740 line: 2,
741 }]
742 .into_iter()
743 .collect(),
744 },
745 );
746 s
747 }
748
749 #[test]
750 fn header_counts_and_no_date_by_default() {
751 let out = render_keel_toml(&host_scan(), &[], None);
752 assert!(
753 out.starts_with("# Generated by keel init from 2 static scans + 0 observed runs\n")
754 );
755 assert!(!out.contains("202"), "no date without --stamp");
756 }
757
758 #[test]
759 fn stamp_adds_a_date() {
760 let out = render_keel_toml(&host_scan(), &[], Some("2026-07-12"));
761 assert!(out.lines().next().unwrap().ends_with(" (2026-07-12)"));
762 }
763
764 #[test]
765 fn host_and_llm_blocks_cite_evidence() {
766 let out = render_keel_toml(&host_scan(), &[], None);
767 assert!(out.contains("[target.\"api.example.com\"]"));
768 assert!(out.contains("# seen in: app.py:4"));
769 assert!(out.contains("[target.\"llm:openai\"]"));
770 assert!(out.contains("# seen in: app.py:2"));
771 assert!(out.contains("cache = { mode = \"dev\" }"));
772 }
773
774 #[test]
775 fn discovery_only_target_is_surfaced_with_observed_comment() {
776 let scan = ScanResult {
777 files_scanned: 1,
778 python_available: true,
779 ..ScanResult::default()
780 };
781 let stats = TargetStats {
782 target: "api.dynamic.com".to_owned(),
783 calls: 120,
784 attempts: 132,
785 retries: 12,
786 successes: 120,
787 failures: 0,
788 cache_hits: 0,
789 throttled: 0,
790 breaker_opens: 0,
791 total_latency_ms: 12_000,
792 max_latency_ms: 300,
793 first_seen_ms: 0,
794 last_seen_ms: 120_000, last_error_class: None,
796 last_error_status: None,
797 not_retried: 0,
798 unwrapped_calls: 0,
799 };
800 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
801 assert!(out.contains("[target.\"api.dynamic.com\"]"));
802 assert!(out.contains("# seen only at runtime (.keel/discovery.db)"));
803 assert!(out.contains("# observed: 120 calls, 12 retries, ~60.0/min mean"));
804 assert!(out.contains("+ 1 observed run\n"));
806 }
807
808 #[test]
809 fn error_class_import_is_available() {
810 let _ = ErrorClass::Http;
812 }
813
814 #[test]
815 fn default_body_matches_the_frozen_pack() {
816 let defaults: toml::Value = include_str!("../contract/defaults.toml")
818 .parse()
819 .expect("defaults.toml parses");
820 let outbound = &defaults["defaults"]["outbound"];
821 assert_eq!(outbound["timeout"].as_str(), Some("30s"));
822 assert_eq!(outbound["retry"]["attempts"].as_integer(), Some(3));
823 assert_eq!(outbound["breaker"]["cooldown"].as_str(), Some("15s"));
824 let llm = &defaults["defaults"]["llm"];
825 assert_eq!(llm["timeout"].as_str(), Some("120s"));
826 assert_eq!(llm["retry"]["attempts"].as_integer(), Some(6));
827 assert_eq!(llm["breaker"]["cooldown"].as_str(), Some("30s"));
828 assert_eq!(llm["cache"]["mode"].as_str(), Some("dev"));
829 assert!(policy_body(TargetClass::Host).contains("attempts = 3"));
831 assert!(policy_body(TargetClass::Host).contains("cooldown = \"15s\""));
832 assert!(policy_body(TargetClass::Llm).contains("attempts = 6"));
833 assert!(policy_body(TargetClass::Llm).contains("cooldown = \"30s\""));
834 assert!(policy_body(TargetClass::Llm).contains("mode = \"dev\""));
835 }
836
837 #[test]
838 fn civil_date_epoch_is_1970_01_01() {
839 assert_eq!(civil_from_days(0), (1970, 1, 1));
840 assert_eq!(civil_from_days(19_997), (2024, 10, 1));
841 }
842
843 #[test]
846 fn round_up_clean_walks_the_1_2_5_series() {
847 assert_eq!(round_up_clean(0), 0);
848 assert_eq!(round_up_clean(1), 1);
849 assert_eq!(round_up_clean(3), 5);
850 assert_eq!(round_up_clean(6), 10);
851 assert_eq!(round_up_clean(11), 20);
852 assert_eq!(round_up_clean(50), 50);
853 assert_eq!(round_up_clean(60), 100);
854 assert_eq!(round_up_clean(123), 200);
855 assert_eq!(round_up_clean(300), 500);
856 assert_eq!(round_up_clean(501), 1_000);
857 }
858
859 #[test]
860 fn llm_rate_is_mean_times_three_rounded_up_to_a_clean_value() {
861 let s = llm_stats(200, 0, 120_000);
863 assert_eq!(mean_per_min_floor(&s), 100);
864 assert_eq!(llm_rate_per_min(&s), 500);
865 }
866
867 #[test]
868 fn llm_rate_floors_at_60_for_sparse_traffic() {
869 let s = llm_stats(5, 0, 60_000);
871 assert_eq!(mean_per_min_floor(&s), 5);
872 assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
873 }
874
875 #[test]
876 fn llm_rate_zero_span_window_falls_back_to_floor() {
877 let s = llm_stats(500, 1_000, 1_000);
879 assert_eq!(mean_per_min_floor(&s), 0);
880 assert_eq!(llm_rate_per_min(&s), LLM_RATE_FLOOR_PER_MIN);
881 }
882
883 #[test]
884 fn observed_llm_target_gets_an_active_rate_line() {
885 let scan = ScanResult {
886 files_scanned: 1,
887 python_available: true,
888 ..ScanResult::default()
889 };
890 let stats = llm_stats(200, 0, 120_000);
891 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
892
893 assert!(out.contains("[target.\"llm:openai\"]"));
894 assert!(out.contains("rate = \"500/min\""));
895 assert!(out.contains("# headroom over your observed mean of ~100/min"));
896 assert!(!out.contains("peak"));
898 let rate_at = out.find("rate =").expect("rate line present");
900 let cache_at = out.find("cache =").expect("cache line present");
901 assert!(rate_at < cache_at, "rate must precede cache");
902 }
903
904 #[test]
905 fn zero_span_llm_target_emits_floor_with_honest_comment() {
906 let scan = ScanResult {
907 files_scanned: 1,
908 python_available: true,
909 ..ScanResult::default()
910 };
911 let stats = llm_stats(9, 5_000, 5_000);
912 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
913 assert!(out.contains("rate = \"60/min\""));
914 assert!(out.contains("# floor: single observation window, no mean to derive"));
915 assert!(!out.contains("peak"));
916 }
917
918 #[test]
919 fn observed_host_target_stays_comments_only() {
920 let scan = ScanResult {
922 files_scanned: 1,
923 python_available: true,
924 ..ScanResult::default()
925 };
926 let stats = TargetStats {
927 target: "api.host.example".to_owned(),
928 ..llm_stats(200, 0, 120_000)
929 };
930 let out = render_keel_toml(&scan, std::slice::from_ref(&stats), None);
931 assert!(out.contains("[target.\"api.host.example\"]"));
932 assert!(
933 !out.contains("rate ="),
934 "host targets must not emit an active rate line"
935 );
936 }
937
938 #[test]
941 fn refuses_when_keel_toml_already_exists() {
942 let dir = TempDir::new().unwrap();
943 fs::write(dir.path().join("keel.toml"), "# hand-written\n").unwrap();
944
945 let r = run(dir.path(), InitOptions::default());
946
947 assert_eq!(r.exit, EXIT_USAGE);
948 assert!(r.to_stderr);
949 assert!(r.human.contains("already exists"));
950 assert_eq!(
952 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
953 "# hand-written\n"
954 );
955 }
956
957 #[test]
958 fn diff_reports_added_and_removed_targets_precisely() {
959 let dir = TempDir::new().unwrap();
960 fs::write(
962 dir.path().join("app.mjs"),
963 "const r = await fetch(\"https://api.example.com/v1/x\");\n",
964 )
965 .unwrap();
966 fs::write(
968 dir.path().join("keel.toml"),
969 "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n",
970 )
971 .unwrap();
972
973 let r = run(
974 dir.path(),
975 InitOptions {
976 diff: true,
977 stamp: false,
978 agents: false,
979 },
980 );
981
982 assert_eq!(r.exit, crate::EXIT_OK);
983 assert_eq!(
984 r.json["added"].as_array().unwrap(),
985 &vec![serde_json::json!("api.example.com")]
986 );
987 assert_eq!(
988 r.json["removed"].as_array().unwrap(),
989 &vec![serde_json::json!("api.gone.example")]
990 );
991 assert!(r.json["unchanged"].as_array().unwrap().is_empty());
992 assert!(r.human.contains("+ [target.\"api.example.com\"]"));
993 assert!(r.human.contains("- [target.\"api.gone.example\"]"));
994 assert_eq!(
996 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
997 "[target.\"api.gone.example\"]\ntimeout = \"30s\"\n"
998 );
999 }
1000
1001 #[test]
1005 fn diff_emits_an_applyable_patch_and_structured_changes() {
1006 let dir = TempDir::new().unwrap();
1007 fs::write(
1008 dir.path().join("app.mjs"),
1009 "// two targets, one already in keel.toml\nconst KEPT = await fetch(\"https://api.example.com/v1/x\");\nconst ADDED = await fetch(\"https://api.new.example/v2/y\");\n",
1010 )
1011 .unwrap();
1012 let old = "\
1013# hand-tuned: keep this comment
1014
1015[target.\"api.example.com\"]
1016timeout = \"9s\" # user tuning survives
1017
1018[target.\"api.gone.example\"] # stale
1019timeout = \"5s\"
1020";
1021 fs::write(dir.path().join("keel.toml"), old).unwrap();
1022
1023 let r = run(
1024 dir.path(),
1025 InitOptions {
1026 diff: true,
1027 stamp: false,
1028 agents: false,
1029 },
1030 );
1031
1032 assert_eq!(r.exit, crate::EXIT_OK);
1033 let patch = r.json["patch"].as_str().unwrap();
1034 assert!(
1035 patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"),
1036 "{patch}"
1037 );
1038 assert!(r.human.contains("apply with `git apply`"));
1039 assert!(
1040 r.human.contains(patch),
1041 "the human output carries the patch verbatim"
1042 );
1043
1044 let applied = crate::diff::apply_unified(old, patch).unwrap();
1045 let value: toml::Value = applied.parse().expect("applied file parses");
1046 assert!(value["target"].get("api.gone.example").is_none());
1047 assert!(value["target"].get("api.new.example").is_some());
1048 assert!(applied.contains("# hand-tuned: keep this comment"));
1049 assert!(applied.contains("timeout = \"9s\" # user tuning survives"));
1050 assert!(applied.contains("[target.\"api.new.example\"]"));
1052 assert!(applied.contains("# seen in: app.mjs:3"));
1053
1054 let changes = r.json["changes"].as_array().unwrap();
1056 let paths: Vec<&str> = changes
1057 .iter()
1058 .map(|c| c["path"].as_str().unwrap())
1059 .collect();
1060 assert_eq!(
1061 paths,
1062 ["target.\"api.gone.example\"", "target.\"api.new.example\""]
1063 );
1064 assert!(changes[0]["after"].is_null());
1065 assert!(changes[1]["before"].is_null());
1066 assert_eq!(
1068 fs::read_to_string(dir.path().join("keel.toml")).unwrap(),
1069 old
1070 );
1071 }
1072
1073 #[test]
1076 fn diff_without_existing_file_is_a_dev_null_creation_patch() {
1077 let dir = TempDir::new().unwrap();
1078 fs::write(
1079 dir.path().join("app.mjs"),
1080 "const r = await fetch(\"https://api.example.com/v1/x\");\n",
1081 )
1082 .unwrap();
1083
1084 let r = run(
1085 dir.path(),
1086 InitOptions {
1087 diff: true,
1088 stamp: false,
1089 agents: false,
1090 },
1091 );
1092
1093 assert_eq!(r.exit, crate::EXIT_OK);
1094 let patch = r.json["patch"].as_str().unwrap();
1095 assert!(
1096 patch.starts_with("--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,"),
1097 "{patch}"
1098 );
1099 let scanned = scan::scan(dir.path());
1100 let expected = render_keel_toml(&scanned, &[], None);
1101 assert_eq!(crate::diff::apply_unified("", patch).unwrap(), expected);
1102 assert_eq!(
1103 r.json["added"].as_array().unwrap(),
1104 &vec![serde_json::json!("api.example.com")]
1105 );
1106 }
1107
1108 #[test]
1111 fn agents_creates_then_is_idempotent() {
1112 let dir = TempDir::new().unwrap();
1113 let opts = InitOptions {
1114 agents: true,
1115 ..InitOptions::default()
1116 };
1117 let r1 = run(dir.path(), opts);
1118 assert_eq!(r1.exit, crate::EXIT_OK);
1119 assert!(r1.json["wrote"].as_bool().unwrap());
1120 let path = dir.path().join("AGENTS.md");
1121 let c1 = fs::read_to_string(&path).unwrap();
1122 assert!(c1.contains("## Keel (resilience & durable execution)"));
1123 assert!(c1.contains("keel doctor --json"));
1124
1125 let r2 = run(dir.path(), opts);
1127 assert!(r2.json["already_current"].as_bool().unwrap());
1128 assert_eq!(fs::read_to_string(&path).unwrap(), c1);
1129 }
1130
1131 #[test]
1132 fn splice_appends_then_replaces_region_without_duplicating() {
1133 let block = agents_block();
1134 let (out, replaced, wrote) = splice_agents_block("# My project\n", &block);
1136 assert!(!replaced && wrote);
1137 assert!(out.starts_with("# My project\n\n"));
1138 assert!(out.contains(AGENTS_BEGIN) && out.contains(AGENTS_END));
1139 let (out2, replaced2, wrote2) = splice_agents_block(&out, &block);
1141 assert!(replaced2 && !wrote2);
1142 assert_eq!(out2, out);
1143 assert_eq!(
1144 out2.matches(AGENTS_BEGIN).count(),
1145 1,
1146 "exactly one Keel block"
1147 );
1148 }
1149
1150 #[test]
1151 fn gitignore_is_created_when_absent() {
1152 let dir = TempDir::new().unwrap();
1153 assert!(update_gitignore(dir.path()).unwrap());
1154 assert_eq!(
1155 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1156 ".keel/\n"
1157 );
1158 }
1159
1160 #[test]
1161 fn gitignore_is_appended_when_keel_line_missing() {
1162 let dir = TempDir::new().unwrap();
1163 fs::write(dir.path().join(".gitignore"), "node_modules/\n*.log").unwrap();
1165
1166 assert!(update_gitignore(dir.path()).unwrap());
1167
1168 assert_eq!(
1169 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1170 "node_modules/\n*.log\n.keel/\n"
1171 );
1172 }
1173
1174 #[test]
1175 fn gitignore_is_a_noop_when_already_ignored() {
1176 let dir = TempDir::new().unwrap();
1177 let original = "build/\n.keel/\ncoverage/\n";
1178 fs::write(dir.path().join(".gitignore"), original).unwrap();
1179
1180 assert!(!update_gitignore(dir.path()).unwrap());
1181
1182 assert_eq!(
1183 fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
1184 original
1185 );
1186 }
1187}