Skip to main content

lean_ctx/core/finops_export/
aliases.rs

1//! Customer-side `repo_hash -> readable project name` mapping for FinOps
2//! showback (GL #668).
3//!
4//! The savings ledger only ever stores a **truncated repo hash** for a project
5//! (never a path or any content), so the FinOps export is privacy-preserving by
6//! construction. Enterprise chargeback, however, needs human-readable team /
7//! project names. This module resolves those names **at export time only**: the
8//! ledger, the signed batch and the hash chain are never touched, so the privacy
9//! guarantees and signatures stay intact.
10//!
11//! The mapping is **opt-in**: it lives in a side file (`finops-aliases.toml`),
12//! not in the ledger. Unmapped hashes fall back to the hash, so an incomplete
13//! mapping never drops rows.
14//!
15//! ## File format (`<config_dir>/finops-aliases.toml`)
16//! ```toml
17//! [projects]
18//! # <repo_hash> = "<display name>"
19//! a1b2c3d4e5 = "Payments"
20//! deadbeef00 = "Platform / SRE"
21//! ```
22
23use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26use serde::Deserialize;
27
28use super::DailyCostRow;
29
30/// Env override pointing at an explicit aliases file (containers / CI).
31pub const ALIASES_ENV: &str = "LEAN_CTX_FINOPS_ALIASES";
32
33/// Filename of the mapping under the config dir.
34const ALIASES_FILE: &str = "finops-aliases.toml";
35
36#[derive(Debug, Deserialize, Default)]
37struct AliasFile {
38    #[serde(default)]
39    projects: BTreeMap<String, String>,
40}
41
42/// A resolved `repo_hash -> display name` mapping. Empty = no mapping installed
43/// (the common case), in which case every operation is a no-op and the export is
44/// byte-for-byte identical to the unmapped output.
45#[derive(Debug, Clone, Default)]
46pub struct ProjectAliases {
47    map: BTreeMap<String, String>,
48}
49
50impl ProjectAliases {
51    /// The canonical installed location (`<config_dir>/finops-aliases.toml`).
52    pub fn default_path() -> Option<PathBuf> {
53        crate::core::paths::config_dir()
54            .ok()
55            .map(|d| d.join(ALIASES_FILE))
56    }
57
58    /// Resolve the active mapping from the source chain: an `explicit` path
59    /// (the `--aliases=` flag) → `LEAN_CTX_FINOPS_ALIASES` → the installed file.
60    /// A missing file yields an empty (no-op) mapping; a malformed file is
61    /// logged and treated as empty, so a typo never breaks the export.
62    #[must_use]
63    pub fn load(explicit: Option<&Path>) -> Self {
64        let Some(path) = Self::source_path(explicit) else {
65            return Self::default();
66        };
67        match std::fs::read_to_string(&path) {
68            Ok(text) => Self::parse(&text).unwrap_or_else(|e| {
69                tracing::warn!(
70                    "finops aliases: ignoring unreadable {} ({e})",
71                    path.display()
72                );
73                Self::default()
74            }),
75            Err(e) => {
76                tracing::warn!("finops aliases: cannot read {} ({e})", path.display());
77                Self::default()
78            }
79        }
80    }
81
82    fn source_path(explicit: Option<&Path>) -> Option<PathBuf> {
83        if let Some(p) = explicit {
84            return Some(p.to_path_buf());
85        }
86        if let Ok(env) = std::env::var(ALIASES_ENV) {
87            let trimmed = env.trim();
88            if !trimmed.is_empty() {
89                return Some(PathBuf::from(trimmed));
90            }
91        }
92        Self::default_path().filter(|p| p.exists())
93    }
94
95    fn parse(text: &str) -> Result<Self, String> {
96        let parsed: AliasFile = toml::from_str(text).map_err(|e| e.to_string())?;
97        Ok(Self {
98            map: parsed.projects,
99        })
100    }
101
102    #[must_use]
103    pub fn is_empty(&self) -> bool {
104        self.map.is_empty()
105    }
106
107    #[must_use]
108    pub fn len(&self) -> usize {
109        self.map.len()
110    }
111
112    /// The display name for a `repo_hash`, or the hash itself when unmapped.
113    #[must_use]
114    pub fn resolve(&self, repo_hash: &str) -> String {
115        self.map
116            .get(repo_hash)
117            .cloned()
118            .unwrap_or_else(|| repo_hash.to_string())
119    }
120
121    /// Relabel the `project` of each row in place. No-op when the mapping is
122    /// empty. Only the export rows are touched — never the ledger.
123    pub fn apply(&self, rows: &mut [DailyCostRow]) {
124        if self.map.is_empty() {
125            return;
126        }
127        for row in rows.iter_mut() {
128            if let Some(name) = self.map.get(&row.project) {
129                row.project.clone_from(name);
130            }
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn rows() -> Vec<DailyCostRow> {
140        vec![
141            DailyCostRow {
142                date: "2026-06-01".into(),
143                project: "a1b2c3".into(),
144                agent_role: "coder".into(),
145                model: "claude".into(),
146                tool: "ctx_read".into(),
147                tokens_actual: 1,
148                tokens_saved: 1,
149                cost_usd: 0.0,
150                savings_usd: 0.0,
151            },
152            DailyCostRow {
153                date: "2026-06-01".into(),
154                project: "unmapped".into(),
155                agent_role: "coder".into(),
156                model: "claude".into(),
157                tool: "ctx_read".into(),
158                tokens_actual: 1,
159                tokens_saved: 1,
160                cost_usd: 0.0,
161                savings_usd: 0.0,
162            },
163        ]
164    }
165
166    #[test]
167    fn parse_maps_projects_section() {
168        let a = ProjectAliases::parse("[projects]\na1b2c3 = \"Payments\"\n").unwrap();
169        assert_eq!(a.len(), 1);
170        assert_eq!(a.resolve("a1b2c3"), "Payments");
171    }
172
173    #[test]
174    fn resolve_falls_back_to_hash_when_unmapped() {
175        let a = ProjectAliases::parse("[projects]\na1b2c3 = \"Payments\"\n").unwrap();
176        assert_eq!(a.resolve("zzz"), "zzz");
177    }
178
179    #[test]
180    fn apply_relabels_only_mapped_rows() {
181        let a = ProjectAliases::parse("[projects]\na1b2c3 = \"Payments\"\n").unwrap();
182        let mut r = rows();
183        a.apply(&mut r);
184        assert_eq!(r[0].project, "Payments");
185        assert_eq!(r[1].project, "unmapped", "unmapped hash stays as-is");
186    }
187
188    #[test]
189    fn empty_mapping_is_noop() {
190        let a = ProjectAliases::default();
191        let mut r = rows();
192        a.apply(&mut r);
193        assert_eq!(r[0].project, "a1b2c3");
194        assert!(a.is_empty());
195    }
196
197    #[test]
198    fn malformed_toml_is_error() {
199        assert!(ProjectAliases::parse("not = [valid").is_err());
200    }
201}