mur_common/
limits_global.rs1use std::path::{Path, PathBuf};
18
19use super::Limits;
20
21pub const GLOBAL_LIMITS_DIR: &str = "limits";
24pub const GLOBAL_LIMITS_FILE: &str = "global.yaml";
27pub const LEGACY_CONFIG_FILE: &str = "config.yaml";
29
30pub fn global_limits_dir(mur_home: &Path) -> PathBuf {
31 mur_home.join(GLOBAL_LIMITS_DIR)
32}
33
34pub fn global_limits_path(mur_home: &Path) -> PathBuf {
35 global_limits_dir(mur_home).join(GLOBAL_LIMITS_FILE)
36}
37
38pub fn legacy_config_path(mur_home: &Path) -> PathBuf {
39 mur_home.join(LEGACY_CONFIG_FILE)
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum GlobalSource {
45 File,
47 Legacy,
49}
50
51impl GlobalSource {
52 pub fn label(self) -> &'static str {
53 match self {
54 GlobalSource::File => "limits/global.yaml",
55 GlobalSource::Legacy => "config.yaml limits (legacy)",
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub struct GlobalLimits {
62 pub limits: Limits,
63 pub source: GlobalSource,
64}
65
66pub fn read_global_file(path: &Path) -> Result<Option<Limits>, String> {
70 let text = match std::fs::read_to_string(path) {
71 Ok(t) => t,
72 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
73 Err(e) => return Err(format!("{}: {e}", path.display())),
74 };
75 if text.trim().is_empty() {
76 return Ok(Some(Limits::default()));
77 }
78 let parsed: Option<Limits> =
79 serde_yaml_ng::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
80 Ok(Some(parsed.unwrap_or_default()))
81}
82
83pub fn load_global(mur_home: &Path) -> Result<GlobalLimits, String> {
88 load_with(mur_home, |p| {
89 Ok(crate::config::Config::load_or_default(p).limits)
90 })
91}
92
93pub fn load_global_strict(mur_home: &Path) -> Result<GlobalLimits, String> {
96 load_with(mur_home, |p| {
97 crate::config::Config::try_load(p).map(|c| c.limits)
98 })
99}
100
101fn load_with(
102 mur_home: &Path,
103 legacy: impl FnOnce(&Path) -> Result<Limits, String>,
104) -> Result<GlobalLimits, String> {
105 if let Some(limits) = read_global_file(&global_limits_path(mur_home))? {
106 return Ok(GlobalLimits {
107 limits,
108 source: GlobalSource::File,
109 });
110 }
111 Ok(GlobalLimits {
112 limits: legacy(&legacy_config_path(mur_home))?,
113 source: GlobalSource::Legacy,
114 })
115}
116
117pub fn legacy_block_present(mur_home: &Path) -> bool {
121 !crate::config::Config::load_or_default(&legacy_config_path(mur_home))
122 .limits
123 .is_empty()
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 fn home() -> tempfile::TempDir {
131 tempfile::tempdir().unwrap()
132 }
133
134 fn write_new(h: &Path, text: &str) {
135 std::fs::create_dir_all(global_limits_dir(h)).unwrap();
136 std::fs::write(global_limits_path(h), text).unwrap();
137 }
138
139 fn write_legacy(h: &Path, text: &str) {
140 std::fs::write(legacy_config_path(h), text).unwrap();
141 }
142
143 #[test]
144 fn nothing_on_disk_is_an_empty_legacy_scope() {
145 let h = home();
146 let g = load_global(h.path()).unwrap();
147 assert_eq!(g.source, GlobalSource::Legacy);
148 assert!(g.limits.is_empty());
149 }
150
151 #[test]
152 fn new_file_only() {
153 let h = home();
154 write_new(h.path(), "deadline: 2h\nstuck: off\n");
155 let g = load_global_strict(h.path()).unwrap();
156 assert_eq!(g.source, GlobalSource::File);
157 assert_eq!(g.limits.deadline.as_deref(), Some("2h"));
158 assert_eq!(g.limits.stuck.as_deref(), Some("off"));
159 }
160
161 #[test]
162 fn legacy_only_falls_back_to_config_yaml() {
163 let h = home();
164 write_legacy(h.path(), "limits:\n cost_usd: 3\n");
165 let g = load_global(h.path()).unwrap();
166 assert_eq!(g.source, GlobalSource::Legacy);
167 assert_eq!(g.limits.cost_usd, Some(3.0));
168 assert!(legacy_block_present(h.path()));
169 }
170
171 #[test]
172 fn both_present_the_new_file_wins_outright() {
173 let h = home();
174 write_legacy(h.path(), "limits:\n deadline: 9h\n cost_usd: 3\n");
175 write_new(h.path(), "deadline: 1h\n");
176 let g = load_global(h.path()).unwrap();
177 assert_eq!(g.source, GlobalSource::File);
178 assert_eq!(g.limits.deadline.as_deref(), Some("1h"));
179 assert_eq!(g.limits.cost_usd, None);
181 assert!(legacy_block_present(h.path()));
182 }
183
184 #[test]
185 fn an_empty_new_file_still_shadows_the_legacy_block() {
186 let h = home();
187 write_legacy(h.path(), "limits:\n deadline: 9h\n");
188 write_new(h.path(), "");
189 let g = load_global(h.path()).unwrap();
190 assert_eq!(g.source, GlobalSource::File);
191 assert!(g.limits.is_empty());
192 }
193
194 #[test]
195 fn a_broken_new_file_is_an_error_not_the_defaults() {
196 let h = home();
197 write_legacy(h.path(), "limits:\n deadline: 9h\n");
198 write_new(h.path(), "deadline: [unclosed\n");
199 assert!(load_global(h.path()).is_err());
200 write_new(h.path(), "max_iterations: 3\n");
201 let e = load_global_strict(h.path()).unwrap_err();
202 assert!(e.contains("max_iterations"), "{e}");
203 }
204
205 #[test]
206 fn legacy_strictness_differs_between_the_two_loaders() {
207 let h = home();
208 write_legacy(h.path(), "limits: [unclosed\n");
209 assert!(load_global(h.path()).unwrap().limits.is_empty());
210 assert!(load_global_strict(h.path()).is_err());
211 }
212}