1use std::collections::{BTreeMap, BTreeSet};
23use std::path::Path;
24use std::sync::OnceLock;
25
26use keel_core_api::policy::Policy;
27use serde::Serialize;
28use serde_json::{Map, Value};
29
30use crate::render::to_json;
31use crate::{EXIT_OK, EXIT_USAGE, Rendered, evidence, scan};
32
33const DEFAULTS_TOML: &str = include_str!("../contract/defaults.toml");
39
40const LAYER_KEY_ORDER: [&str; 5] = ["timeout", "retry", "breaker", "rate", "cache"];
43
44const DEV_CACHE_NOTE: &str = "cache mode \"dev\" is resolved by the front end at run time \
47 (a concrete ttl off-prod; dropped when KEEL_ENV=prod) \u{2014} the policy shown is the \
48 pre-resolution merge.";
49
50struct Pack {
56 lib: &'static str,
57 fragment: fn() -> Value,
58}
59
60const PACKS: &[Pack] = &[
63 Pack {
64 lib: "openai",
65 fragment: llm_pack_fragment,
66 },
67 Pack {
68 lib: "anthropic",
69 fragment: llm_pack_fragment,
70 },
71 Pack {
72 lib: "ai-sdk",
73 fragment: llm_pack_fragment,
74 },
75 Pack {
76 lib: "mcp",
77 fragment: empty_fragment,
78 },
79];
80
81fn level0() -> &'static Map<String, Value> {
83 static LEVEL0: OnceLock<Map<String, Value>> = OnceLock::new();
84 LEVEL0.get_or_init(|| {
85 let toml_value: toml::Value = DEFAULTS_TOML
86 .parse()
87 .expect("contracts/defaults.toml is valid TOML");
88 let json = serde_json::to_value(&toml_value).expect("defaults normalize to JSON");
89 json.get("defaults")
90 .and_then(Value::as_object)
91 .cloned()
92 .expect("contracts/defaults.toml has a [defaults] table")
93 })
94}
95
96fn level0_layer(layer: &str) -> Map<String, Value> {
98 level0()
99 .get(layer)
100 .and_then(Value::as_object)
101 .cloned()
102 .unwrap_or_default()
103}
104
105pub fn llm_pack_fragment() -> Value {
110 let mut defaults = Map::new();
111 defaults.insert("llm".to_owned(), Value::Object(level0_layer("llm")));
112 let mut frag = Map::new();
113 frag.insert("defaults".to_owned(), Value::Object(defaults));
114 Value::Object(frag)
115}
116
117fn empty_fragment() -> Value {
119 Value::Object(Map::new())
120}
121
122fn table(v: Option<&Value>) -> Map<String, Value> {
125 v.and_then(Value::as_object).cloned().unwrap_or_default()
126}
127
128fn pack_layers(fragments: &[Value]) -> (Map<String, Value>, Map<String, Value>) {
131 let mut outbound = Map::new();
132 let mut llm = Map::new();
133 for frag in fragments {
134 let frag_defaults = table(frag.get("defaults"));
135 outbound.extend(table(frag_defaults.get("outbound")));
136 llm.extend(table(frag_defaults.get("llm")));
137 }
138 (outbound, llm)
139}
140
141pub fn effective_policy(user: &Value, fragments: &[Value]) -> Value {
147 let mut out = user.as_object().cloned().unwrap_or_default();
148 let user_defaults = table(out.get("defaults"));
149 let (pack_outbound, pack_llm) = pack_layers(fragments);
150
151 let mut outbound = level0_layer("outbound");
152 outbound.extend(pack_outbound);
153 outbound.extend(table(user_defaults.get("outbound")));
154
155 let mut llm = level0_layer("llm");
156 llm.extend(pack_llm);
157 llm.extend(table(user_defaults.get("llm")));
158
159 let mut defaults = user_defaults;
160 defaults.insert("outbound".to_owned(), Value::Object(outbound));
161 defaults.insert("llm".to_owned(), Value::Object(llm));
162 out.insert("defaults".to_owned(), Value::Object(defaults));
163 Value::Object(out)
164}
165
166fn layer_sources(user: &Value, fragments: &[Value]) -> BTreeMap<String, &'static str> {
172 let user_defaults = table(user.get("defaults"));
173 let (pack_outbound, pack_llm) = pack_layers(fragments);
174 let mut sources = BTreeMap::new();
175 for (layer, pack_keys) in [("outbound", &pack_outbound), ("llm", &pack_llm)] {
176 let user_keys = table(user_defaults.get(layer));
177 let mut merged = level0_layer(layer);
178 merged.extend(pack_keys.clone());
179 merged.extend(user_keys.clone());
180 for key in merged.keys() {
181 let source = if user_keys.contains_key(key) {
182 "user"
183 } else if pack_keys.contains_key(key) {
184 "pack"
185 } else {
186 "defaults"
187 };
188 sources.insert(format!("defaults.{layer}.{key}"), source);
189 }
190 }
191 sources
192}
193
194fn has_dev_cache(policy: &Value) -> bool {
198 let defaults = table(policy.get("defaults"));
199 if cache_is_dev(defaults.get("llm")) || cache_is_dev(defaults.get("outbound")) {
200 return true;
201 }
202 table(policy.get("target"))
203 .values()
204 .any(|t| cache_is_dev(Some(t)))
205}
206
207fn cache_is_dev(layer: Option<&Value>) -> bool {
208 layer
209 .and_then(|l| l.get("cache"))
210 .and_then(|c| c.get("mode"))
211 .and_then(Value::as_str)
212 == Some("dev")
213}
214
215#[derive(Debug, Serialize)]
219struct EffectiveReport {
220 notes: Vec<&'static str>,
221 packs: Vec<&'static str>,
222 policy: Value,
223 sources: BTreeMap<String, &'static str>,
224 user_policy_present: bool,
225}
226
227pub fn run(project: &Path) -> Rendered {
229 let user = match load_user_policy(&evidence::keel_toml(project)) {
230 UserPolicy::Invalid { field, message } => {
231 return invalid_rendered(field.as_deref(), &message);
232 }
233 UserPolicy::Absent => None,
234 UserPolicy::Valid(v) => Some(v),
235 };
236 let scanned = scan::scan(project);
237 let report = build_report(&scanned.libs, user);
238 let human = human(&report);
239 Rendered::ok(human, to_json(&report)).with_exit(EXIT_OK)
240}
241
242fn build_report(libs: &BTreeSet<String>, user: Option<Value>) -> EffectiveReport {
245 let detected: Vec<&Pack> = PACKS.iter().filter(|p| libs.contains(p.lib)).collect();
246 let fragments: Vec<Value> = detected.iter().map(|p| (p.fragment)()).collect();
247 let user_policy_present = user.is_some();
248 let user = user.unwrap_or(Value::Object(Map::new()));
249
250 let policy = effective_policy(&user, &fragments);
251 let sources = layer_sources(&user, &fragments);
252 let notes = if has_dev_cache(&policy) {
253 vec![DEV_CACHE_NOTE]
254 } else {
255 Vec::new()
256 };
257 EffectiveReport {
258 notes,
259 packs: detected.iter().map(|p| p.lib).collect(),
260 policy,
261 sources,
262 user_policy_present,
263 }
264}
265
266enum UserPolicy {
269 Absent,
270 Valid(Value),
271 Invalid {
272 field: Option<String>,
273 message: String,
274 },
275}
276
277fn load_user_policy(path: &Path) -> UserPolicy {
278 if !path.exists() {
279 return UserPolicy::Absent;
280 }
281 let Ok(text) = std::fs::read_to_string(path) else {
282 return UserPolicy::Invalid {
283 field: None,
284 message: "keel.toml exists but could not be read".to_owned(),
285 };
286 };
287 let toml_value: toml::Value = match text.parse() {
288 Ok(v) => v,
289 Err(e) => {
290 return UserPolicy::Invalid {
291 field: None,
292 message: format!("keel.toml is not valid TOML: {e}"),
293 };
294 }
295 };
296 let json_value = match serde_json::to_value(&toml_value) {
297 Ok(v) => v,
298 Err(e) => {
299 return UserPolicy::Invalid {
300 field: None,
301 message: format!("keel.toml could not be normalized: {e}"),
302 };
303 }
304 };
305 match serde_path_to_error::deserialize::<_, Policy>(&json_value) {
306 Ok(_) => UserPolicy::Valid(json_value),
307 Err(e) => UserPolicy::Invalid {
308 field: Some(e.path().to_string()),
309 message: e.inner().to_string(),
310 },
311 }
312}
313
314fn invalid_rendered(field: Option<&str>, message: &str) -> Rendered {
318 let at = field.unwrap_or("(document)");
319 let human = format!(
320 "keel \u{25b8} doctor --effective-policy: keel.toml is invalid (KEEL-E001), so there is \
321 no effective policy to compose.\n at `{at}`: {message}\n \u{2192} Fix the field and \
322 re-run; `keel explain KEEL-E001` has the contract, `keel doctor` the full report.\n"
323 );
324 let json = to_json(&serde_json::json!({
325 "code": "KEEL-E001",
326 "error": message,
327 "field": field,
328 }));
329 Rendered::ok(human, json).with_exit(EXIT_USAGE).on_stderr()
330}
331
332fn toml_string(s: &str) -> String {
336 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
337}
338
339fn toml_key(k: &str) -> String {
341 let bare = !k.is_empty()
342 && k.bytes()
343 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
344 if bare { k.to_owned() } else { toml_string(k) }
345}
346
347fn toml_value(v: &Value) -> String {
351 match v {
352 Value::String(s) => toml_string(s),
353 Value::Bool(b) => b.to_string(),
354 Value::Number(n) => n.to_string(),
355 Value::Null => "null".to_owned(),
356 Value::Array(items) => {
357 let inner: Vec<String> = items.iter().map(toml_value).collect();
358 format!("[{}]", inner.join(", "))
359 }
360 Value::Object(m) => {
361 if m.is_empty() {
362 "{}".to_owned()
363 } else {
364 let inner: Vec<String> = m
365 .iter()
366 .map(|(k, v)| format!("{} = {}", toml_key(k), toml_value(v)))
367 .collect();
368 format!("{{ {} }}", inner.join(", "))
369 }
370 }
371 }
372}
373
374fn ordered_keys(m: &Map<String, Value>) -> Vec<&String> {
377 let mut keys: Vec<&String> = LAYER_KEY_ORDER
378 .iter()
379 .filter_map(|k| m.get_key_value(*k).map(|(k, _)| k))
380 .collect();
381 keys.extend(m.keys().filter(|k| !LAYER_KEY_ORDER.contains(&k.as_str())));
382 keys
383}
384
385fn render_table(
388 out: &mut String,
389 header_line: &str,
390 m: &Map<String, Value>,
391 annotate: &dyn Fn(&str) -> Option<&'static str>,
392) {
393 let header = format!("\n{header_line}\n");
394 out.push_str(&header);
395 let width = m.keys().map(String::len).max().unwrap_or(0);
396 for key in ordered_keys(m) {
397 let value = toml_value(&m[key.as_str()]);
398 let line = match annotate(key) {
399 Some(source) => format!("{key:<width$} = {value} # {source}\n"),
400 None => format!("{key:<width$} = {value}\n"),
401 };
402 out.push_str(&line);
403 }
404}
405
406fn human(r: &EffectiveReport) -> String {
409 let mut out = String::from("keel \u{25b8} doctor --effective-policy\n\n");
410 out.push_str(
411 "The composed policy `keel_configure` receives \u{2014} defaults < packs < user,\n\
412 merged per key; the core layers no pack underneath.\n",
413 );
414 out.push_str(" defaults embedded contracts/defaults.toml (Level 0)\n");
415 let packs = if r.packs.is_empty() {
416 "(none detected)".to_owned()
417 } else {
418 r.packs.join(", ")
419 };
420 let packs_line = format!(" packs {packs}\n");
421 out.push_str(&packs_line);
422 let user_line = if r.user_policy_present {
423 "keel.toml"
424 } else {
425 "(no keel.toml \u{2014} Level 0 applies)"
426 };
427 let user_row = format!(" user {user_line}\n");
428 out.push_str(&user_row);
429
430 let policy = table(Some(&r.policy));
431 let defaults = table(policy.get("defaults"));
432
433 for layer in ["outbound", "llm"] {
435 if let Some(Value::Object(m)) = defaults.get(layer) {
436 render_table(&mut out, &format!("[defaults.{layer}]"), m, &|key| {
437 r.sources.get(&format!("defaults.{layer}.{key}")).copied()
438 });
439 }
440 }
441 for (key, v) in &defaults {
444 if key != "outbound"
445 && key != "llm"
446 && let Value::Object(m) = v
447 {
448 render_table(
449 &mut out,
450 &format!("[defaults.{}] # user", toml_key(key)),
451 m,
452 &|_| None,
453 );
454 }
455 }
456 for (key, v) in &policy {
458 if key == "defaults" || key == "target" {
459 continue;
460 }
461 if let Value::Object(m) = v {
462 render_table(
463 &mut out,
464 &format!("[{}] # user", toml_key(key)),
465 m,
466 &|_| None,
467 );
468 } else {
469 let line = format!("\n{} = {} # user\n", toml_key(key), toml_value(v));
470 out.push_str(&line);
471 }
472 }
473 if let Some(Value::Object(targets)) = policy.get("target") {
475 for (name, t) in targets {
476 let header = format!(
477 "[target.{}] # user \u{2014} pass-through (engine resolves target \u{2192} \
478 defaults per key)",
479 toml_string(name)
480 );
481 if let Value::Object(m) = t {
482 render_table(&mut out, &header, m, &|_| None);
483 }
484 }
485 }
486 for note in &r.notes {
487 let line = format!("\nnote: {note}\n");
488 out.push_str(&line);
489 }
490 out
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use serde_json::json;
497
498 #[test]
501 fn embedded_defaults_match_contract_values() {
502 let outbound = level0_layer("outbound");
503 assert_eq!(outbound["timeout"], json!("30s"));
504 assert_eq!(
505 outbound["retry"],
506 json!({
507 "attempts": 3,
508 "schedule": "exp(200ms, x2, max 30s, jitter)",
509 "on": ["conn", "timeout", "429", "5xx"],
510 })
511 );
512 assert_eq!(
513 outbound["breaker"],
514 json!({ "failures": 5, "cooldown": "15s" })
515 );
516
517 let llm = level0_layer("llm");
518 assert_eq!(llm["timeout"], json!("120s"));
519 assert_eq!(llm["retry"]["attempts"], json!(6));
520 assert_eq!(llm["breaker"], json!({ "failures": 5, "cooldown": "30s" }));
521 assert_eq!(llm["cache"], json!({ "mode": "dev" }));
522 }
523
524 #[test]
527 fn empty_policy_gets_full_pack_layers() {
528 let merged = effective_policy(&json!({}), &[]);
529 assert_eq!(
530 merged["defaults"]["outbound"],
531 Value::Object(level0_layer("outbound"))
532 );
533 assert_eq!(
534 merged["defaults"]["llm"],
535 Value::Object(level0_layer("llm"))
536 );
537 }
538
539 #[test]
541 fn user_key_replaces_layer_key_wholesale_and_targets_pass_through() {
542 let user = json!({
543 "defaults": { "llm": { "retry": { "attempts": 2 } } },
544 "target": { "x": {} },
545 });
546 let merged = effective_policy(&user, &[]);
547 assert_eq!(
548 merged["defaults"]["llm"]["retry"],
549 json!({ "attempts": 2 }),
550 "user retry wins wholesale"
551 );
552 assert_eq!(
553 merged["defaults"]["llm"]["cache"],
554 json!({ "mode": "dev" }),
555 "pack cache kept"
556 );
557 assert_eq!(
558 merged["defaults"]["llm"]["breaker"],
559 json!({ "failures": 5, "cooldown": "30s" }),
560 "pack breaker kept"
561 );
562 assert_eq!(
563 merged["target"],
564 json!({ "x": {} }),
565 "target tables untouched"
566 );
567 }
568
569 #[test]
571 fn idempotent_on_level0() {
572 let level0_policy = Value::Object(
573 [("defaults".to_owned(), Value::Object(level0().clone()))]
574 .into_iter()
575 .collect(),
576 );
577 assert_eq!(effective_policy(&level0_policy, &[]), level0_policy);
578 }
579
580 #[test]
582 fn provider_fragments_fold_as_identity() {
583 let with = effective_policy(&json!({}), &[llm_pack_fragment(), llm_pack_fragment()]);
584 let without = effective_policy(&json!({}), &[]);
585 assert_eq!(with, without);
586 }
587
588 #[test]
589 fn sources_classify_user_pack_and_defaults() {
590 let user = json!({ "defaults": { "llm": { "retry": { "attempts": 2 } } } });
591 let sources = layer_sources(&user, &[llm_pack_fragment()]);
592 assert_eq!(sources["defaults.llm.retry"], "user");
593 assert_eq!(sources["defaults.llm.cache"], "pack");
594 assert_eq!(sources["defaults.llm.timeout"], "pack");
595 assert_eq!(sources["defaults.outbound.timeout"], "defaults");
596 assert_eq!(sources["defaults.outbound.retry"], "defaults");
597 }
598
599 #[test]
600 fn dev_cache_note_tracks_the_composed_policy() {
601 let with_default_cache = build_report(&BTreeSet::new(), None);
602 assert_eq!(with_default_cache.notes, vec![DEV_CACHE_NOTE]);
603
604 let user = json!({ "defaults": { "llm": { "cache": { "ttl": "1h" } } } });
607 let report = build_report(&BTreeSet::new(), Some(user));
608 assert!(report.notes.is_empty());
609 }
610
611 #[test]
612 fn packs_are_detected_from_import_evidence_in_registration_order() {
613 let libs: BTreeSet<String> = ["anthropic", "openai", "requests"]
614 .into_iter()
615 .map(str::to_owned)
616 .collect();
617 let report = build_report(&libs, None);
618 assert_eq!(report.packs, vec!["openai", "anthropic"]);
619 assert_eq!(report.sources["defaults.llm.cache"], "pack");
622 assert_eq!(report.sources["defaults.outbound.timeout"], "defaults");
623 }
624
625 #[test]
626 fn toml_rendering_quotes_and_sorts() {
627 assert_eq!(toml_value(&json!("a\"b")), "\"a\\\"b\"");
628 assert_eq!(toml_value(&json!([1, "x"])), "[1, \"x\"]");
629 assert_eq!(
630 toml_value(&json!({ "z": 1, "a": { "mode": "dev" } })),
631 "{ a = { mode = \"dev\" }, z = 1 }"
632 );
633 assert_eq!(toml_key("api.example.com"), "\"api.example.com\"");
634 assert_eq!(toml_key("timeout"), "timeout");
635 }
636
637 #[test]
638 fn invalid_policy_is_keel_e001_usage_error() {
639 let dir = tempfile::TempDir::new().unwrap();
640 std::fs::write(
641 dir.path().join("keel.toml"),
642 "[target.\"x\"]\nretry = { attempts = 0 }\n",
643 )
644 .unwrap();
645 let r = run(dir.path());
646 assert_eq!(r.exit, EXIT_USAGE);
647 assert!(r.to_stderr);
648 assert_eq!(r.json["code"], json!("KEEL-E001"));
649 assert_eq!(r.json["field"], json!("target.x.retry.attempts"));
650 assert!(r.human.contains("KEEL-E001"));
651 assert!(r.human.contains("target.x.retry.attempts"));
652 }
653
654 #[test]
655 fn absent_policy_composes_pure_level0() {
656 let dir = tempfile::TempDir::new().unwrap();
657 let r = run(dir.path());
658 assert_eq!(r.exit, EXIT_OK);
659 assert_eq!(r.json["user_policy_present"], json!(false));
660 assert_eq!(
661 r.json["policy"]["defaults"]["llm"]["timeout"],
662 json!("120s")
663 );
664 assert!(r.human.contains("(no keel.toml \u{2014} Level 0 applies)"));
665 }
666
667 #[test]
670 fn human_carries_the_json_facts() {
671 let libs: BTreeSet<String> = ["openai".to_owned()].into_iter().collect();
672 let user = json!({
673 "defaults": { "llm": { "retry": { "attempts": 2 } } },
674 "target": { "api.example.com": { "retry": { "attempts": 5 } } },
675 });
676 let report = build_report(&libs, Some(user));
677 let text = human(&report);
678 assert!(text.contains("openai"));
679 assert!(text.contains("# user"));
680 assert!(text.contains("# pack"));
681 assert!(text.contains("# defaults"));
682 assert!(text.contains("[target.\"api.example.com\"]"));
683 assert!(text.contains("note: "));
684 }
685}