lean_ctx/core/finops_export/
aliases.rs1use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26use serde::Deserialize;
27
28use super::DailyCostRow;
29
30pub const ALIASES_ENV: &str = "LEAN_CTX_FINOPS_ALIASES";
32
33const 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#[derive(Debug, Clone, Default)]
46pub struct ProjectAliases {
47 map: BTreeMap<String, String>,
48}
49
50impl ProjectAliases {
51 pub fn default_path() -> Option<PathBuf> {
53 crate::core::paths::config_dir()
54 .ok()
55 .map(|d| d.join(ALIASES_FILE))
56 }
57
58 #[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 #[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 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}