| 1 | //! Ranked settings resolution with provenance. |
| 2 | //! |
| 3 | //! One rank table for every setting: the highest-ranked layer that names a |
| 4 | //! scalar wins outright (scalars replace, never merge); map layers |
| 5 | //! deep-merge with the highest rank winning per leaf; secrets travel by |
| 6 | //! reference only — a [`SecretRef`] names the source and id, never the |
| 7 | //! value, so a resolved row can say *which* secret won without carrying it. |
| 8 | //! |
| 9 | //! Layers with no producer today (managed policy files, project settings) |
| 10 | //! simply contribute no entries; the rank is the contract, and an empty |
| 11 | //! layer abstains rather than vetoing. |
| 12 | |
| 13 | use std::collections::BTreeMap; |
| 14 | |
| 15 | /// Where a setting value came from, highest rank first. |
| 16 | /// |
| 17 | /// The order is the product rule: policy beats the CLI flag, the flag beats |
| 18 | /// a session override, overrides beat files, and files beat the default. |
| 19 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 20 | pub enum Layer { |
| 21 | /// Organization / managed policy. No file producer yet. |
| 22 | ManagedPolicy, |
| 23 | /// Per-run `-c key=value` CLI flag (lands with the config CLI slice). |
| 24 | CliFlag, |
| 25 | /// Session override (`/set`, `/config <key> <value>` without `--save`). |
| 26 | SessionOverride, |
| 27 | /// Project settings. No file producer yet. |
| 28 | ProjectConfig, |
| 29 | /// `settings.toml` (user config). |
| 30 | UserConfig, |
| 31 | /// The schema default. |
| 32 | Default, |
| 33 | } |
| 34 | |
| 35 | impl Layer { |
| 36 | /// Stable wire name for receipts and row labels. |
| 37 | #[must_use] |
| 38 | pub fn name(self) -> &'static str { |
| 39 | match self { |
| 40 | Layer::ManagedPolicy => "policy", |
| 41 | Layer::CliFlag => "cli", |
| 42 | Layer::SessionOverride => "session", |
| 43 | Layer::ProjectConfig => "project", |
| 44 | Layer::UserConfig => "user", |
| 45 | Layer::Default => "default", |
| 46 | } |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | /// A secret by reference: the layer that resolved it plus where to find it. |
| 51 | /// The value itself never enters the resolver, a row, or a receipt. |
| 52 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 53 | pub struct SecretRef { |
| 54 | /// Which layer named this secret. |
| 55 | pub layer: Layer, |
| 56 | /// Where it lives: `env:DASHSCOPE_API_KEY`, `keychain:codewhale/...`, … |
| 57 | pub reference: String, |
| 58 | } |
| 59 | |
| 60 | impl SecretRef { |
| 61 | #[must_use] |
| 62 | pub fn new(layer: Layer, reference: impl Into<String>) -> Self { |
| 63 | Self { |
| 64 | layer, |
| 65 | reference: reference.into(), |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | /// The winning layer for one scalar key, with every layer that named it. |
| 71 | /// Empty input means the schema default wins unopposed. |
| 72 | #[must_use] |
| 73 | pub fn resolve_scalar<'a>( |
| 74 | candidates: impl IntoIterator<Item = (Layer, &'a str)>, |
| 75 | ) -> (Layer, Vec<Layer>) { |
| 76 | let mut best: Option<Layer> = None; |
| 77 | let mut named: Vec<Layer> = Vec::new(); |
| 78 | for (layer, _) in candidates { |
| 79 | if !named.contains(&layer) { |
| 80 | named.push(layer); |
| 81 | } |
| 82 | if best.is_none_or(|top| layer < top) { |
| 83 | best = Some(layer); |
| 84 | } |
| 85 | } |
| 86 | named.sort(); |
| 87 | (best.unwrap_or(Layer::Default), named) |
| 88 | } |
| 89 | |
| 90 | /// Deep-merge map layers: every key present anywhere survives, and per key |
| 91 | /// the highest-ranked layer's value wins. Lower layers contribute only the |
| 92 | /// keys nobody above them named. |
| 93 | #[must_use] |
| 94 | pub fn merge_maps(layers: &BTreeMap<Layer, BTreeMap<String, String>>) -> BTreeMap<String, String> { |
| 95 | let mut merged = BTreeMap::new(); |
| 96 | let mut order: Vec<Layer> = layers.keys().copied().collect(); |
| 97 | order.sort(); |
| 98 | for layer in order { |
| 99 | if let Some(map) = layers.get(&layer) { |
| 100 | for (key, value) in map { |
| 101 | merged.entry(key.clone()).or_insert_with(|| value.clone()); |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | merged |
| 106 | } |
| 107 | |
| 108 | /// Which layer supplied each merged key. Same walk as [`merge_maps`], kept |
| 109 | /// as a separate pass so a row can name the winner per key. |
| 110 | #[must_use] |
| 111 | pub fn merge_provenance( |
| 112 | layers: &BTreeMap<Layer, BTreeMap<String, String>>, |
| 113 | ) -> BTreeMap<String, Layer> { |
| 114 | let mut provenance = BTreeMap::new(); |
| 115 | let mut order: Vec<Layer> = layers.keys().copied().collect(); |
| 116 | order.sort(); |
| 117 | for layer in order { |
| 118 | if let Some(map) = layers.get(&layer) { |
| 119 | for key in map.keys() { |
| 120 | provenance.entry(key.clone()).or_insert(layer); |
| 121 | } |
| 122 | } |
| 123 | } |
| 124 | provenance |
| 125 | } |
| 126 | |
| 127 | #[cfg(test)] |
| 128 | mod tests { |
| 129 | use super::*; |
| 130 | |
| 131 | #[test] |
| 132 | fn rank_order_is_policy_over_cli_over_session_over_files_over_default() { |
| 133 | let layers = [ |
| 134 | Layer::ManagedPolicy, |
| 135 | Layer::CliFlag, |
| 136 | Layer::SessionOverride, |
| 137 | Layer::ProjectConfig, |
| 138 | Layer::UserConfig, |
| 139 | Layer::Default, |
| 140 | ]; |
| 141 | let mut sorted = layers; |
| 142 | sorted.sort(); |
| 143 | assert_eq!(sorted, layers); |
| 144 | } |
| 145 | |
| 146 | #[test] |
| 147 | fn scalar_resolution_picks_the_highest_ranked_layer() { |
| 148 | let (winner, named) = resolve_scalar([ |
| 149 | (Layer::Default, "bottom"), |
| 150 | (Layer::UserConfig, "user"), |
| 151 | (Layer::SessionOverride, "session"), |
| 152 | ]); |
| 153 | assert_eq!(winner, Layer::SessionOverride); |
| 154 | assert_eq!( |
| 155 | named, |
| 156 | vec![Layer::SessionOverride, Layer::UserConfig, Layer::Default] |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | #[test] |
| 161 | fn scalar_resolution_with_no_candidates_falls_back_to_default() { |
| 162 | let (winner, named) = resolve_scalar([]); |
| 163 | assert_eq!(winner, Layer::Default); |
| 164 | assert!(named.is_empty()); |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn scalar_values_replace_never_merge() { |
| 169 | // The loser's text is evidence only; the winner's value is untouched. |
| 170 | let (winner, _) = resolve_scalar([(Layer::UserConfig, "user"), (Layer::CliFlag, "cli")]); |
| 171 | assert_eq!(winner, Layer::CliFlag); |
| 172 | } |
| 173 | |
| 174 | #[test] |
| 175 | fn map_merge_keeps_every_key_with_highest_rank_winning_per_key() { |
| 176 | let layers = BTreeMap::from([ |
| 177 | ( |
| 178 | Layer::UserConfig, |
| 179 | BTreeMap::from([ |
| 180 | ("a".to_string(), "user-a".to_string()), |
| 181 | ("b".to_string(), "user-b".to_string()), |
| 182 | ]), |
| 183 | ), |
| 184 | ( |
| 185 | Layer::ProjectConfig, |
| 186 | BTreeMap::from([("b".to_string(), "project-b".to_string())]), |
| 187 | ), |
| 188 | ]); |
| 189 | let merged = merge_maps(&layers); |
| 190 | assert_eq!(merged.get("a").map(String::as_str), Some("user-a")); |
| 191 | assert_eq!(merged.get("b").map(String::as_str), Some("project-b")); |
| 192 | let provenance = merge_provenance(&layers); |
| 193 | assert_eq!(provenance.get("a"), Some(&Layer::UserConfig)); |
| 194 | assert_eq!(provenance.get("b"), Some(&Layer::ProjectConfig)); |
| 195 | } |
| 196 | |
| 197 | #[test] |
| 198 | fn secret_refs_carry_no_value() { |
| 199 | let secret = SecretRef::new(Layer::UserConfig, "env:DASHSCOPE_API_KEY"); |
| 200 | let debug = format!("{secret:?}"); |
| 201 | assert!(debug.contains("env:DASHSCOPE_API_KEY")); |
| 202 | assert_eq!(secret.layer, Layer::UserConfig); |
| 203 | } |
| 204 | |
| 205 | #[test] |
| 206 | fn layer_names_are_stable() { |
| 207 | assert_eq!(Layer::ManagedPolicy.name(), "policy"); |
| 208 | assert_eq!(Layer::CliFlag.name(), "cli"); |
| 209 | assert_eq!(Layer::SessionOverride.name(), "session"); |
| 210 | assert_eq!(Layer::ProjectConfig.name(), "project"); |
| 211 | assert_eq!(Layer::UserConfig.name(), "user"); |
| 212 | assert_eq!(Layer::Default.name(), "default"); |
| 213 | } |
| 214 | } |
| 215 |