Commit 9117440444eb
tarball4 files changed+152-2
@@ -73,6 +73,18 @@ restart omp after linking. install `.#roundabout` on PATH for the agent's bash t
7373
7474
`~/.config/roundabout/config.toml`, falling back to `/etc/roundabout/config.toml`, or `$ROUNDABOUT_CONFIG` over both. the daemon picks up edits on save, on linux anyway. a broken config gets complained about and ignored rather than taking every agent down with it. everything's optional, these are the defaults7575
76
+project settings can live in a hidden `.resrc` file. each client command starts in its current directory and walks upward until it reaches the git root. the first `.resrc` it finds wins, so a nested file takes precedence over one at the repository root. directories outside a git worktree do not load a project file. `.resrc` uses the same TOML settings, and omitted values keep the user or system settings77
+78
+```toml79
+# .resrc80
+[budget]81
+ram = "16GiB"82
+cores = 883
+84
+[artifacts]85
+project_cap = "20GiB"86
+```87
+7688
```toml7789
[budget]7890
ram = "75%" # of total, or "24GiB"@@ -349,6 +349,33 @@ impl Config {
349349
}350350
}351351
352
+ pub fn load_for(cwd: &std::path::Path) -> Result<Self, String> {353
+ let global = paths::config_file();354
+ let mut base = match fs::read_to_string(&global) {355
+ Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", global.display()))?,356
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {357
+ toml::Value::Table(toml::map::Map::new())358
+ }359
+ Err(e) => return Err(format!("{}: {e}", global.display())),360
+ };361
+ if let Some(path) = paths::project_config_file(cwd) {362
+ let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;363
+ let overlay: toml::Value =364
+ toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;365
+ if !overlay.is_table() {366
+ return Err(format!("{}: expected a TOML table", path.display()));367
+ }368
+ merge_values(&mut base, overlay);369
+ }370
+ let mut config: Self = base371
+ .try_into()372
+ .map_err(|e| format!("{}: {e}", global.display()))?;373
+ for (name, preset) in Self::default().sizes {374
+ config.sizes.entry(name).or_insert(preset);375
+ }376
+ Ok(config)377
+ }378
+352379
pub fn parse(text: &str) -> Result<Self, toml::de::Error> {353380
let mut config: Self = toml::from_str(text)?;354381
for (name, preset) in Self::default().sizes {@@ -382,3 +409,43 @@ impl Config {
382409
.map(String::as_str)383410
}384411
}412
+413
+fn merge_values(base: &mut toml::Value, overlay: toml::Value) {414
+ match overlay {415
+ toml::Value::Table(overlay) => {416
+ if let toml::Value::Table(base) = base {417
+ for (key, value) in overlay {418
+ if let Some(existing) = base.get_mut(&key) {419
+ merge_values(existing, value);420
+ } else {421
+ base.insert(key, value);422
+ }423
+ }424
+ } else {425
+ *base = toml::Value::Table(overlay);426
+ }427
+ }428
+ value => *base = value,429
+ }430
+}431
+432
+#[cfg(test)]433
+mod tests {434
+ use super::{Config, merge_values};435
+ use crate::units::Bytes;436
+437
+ #[test]438
+ fn project_values_override_individual_defaults() {439
+ let mut base: toml::Value = toml::from_str(440
+ "[budget]\nram = \"60%\"\nram_reserve = \"2GiB\"\n[queue]\nelastic = false\n",441
+ )442
+ .expect("parse global config");443
+ let project: toml::Value =444
+ toml::from_str("[budget]\nram_reserve = \"8GiB\"\n").expect("parse project config");445
+ merge_values(&mut base, project);446
+ let config: Config = base.try_into().expect("decode merged config");447
+ assert_eq!(config.budget.ram_reserve, Bytes::gib(8));448
+ assert_eq!(config.budget.ram, "60%".parse().expect("parse amount"));449
+ assert!(!config.queue.elastic);450
+ }451
+}@@ -24,9 +24,16 @@ fn main() {
2424
}2525
// a typo must not exit 2, that's a blocking pretooluse verdict and would2626
// wall off every bash call in every session27
- let cfg = config::Config::load().unwrap_or_else(|e| {27
+ let rabt = cli::Rabt::parse();28
+ let cfg = if matches!(&rabt, cli::Rabt::Daemon) {29
+ config::Config::load()30
+ } else {31
+ let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());32
+ config::Config::load_for(&cwd)33
+ }34
+ .unwrap_or_else(|e| {2835
eprintln!("roundabout: bad config, using defaults. {e}");2936
config::Config::default()3037
});31
- std::process::exit(cli::dispatch(&cfg, cli::Rabt::parse()));38
+ std::process::exit(cli::dispatch(&cfg, rabt));3239
}@@ -67,6 +67,25 @@ pub fn spawn_lock() -> PathBuf {
6767
runtime_dir().join("roundabout.lock")6868
}6969
70
+pub fn git_root(cwd: &Path) -> Option<PathBuf> {71
+ cwd.ancestors()72
+ .find(|dir| dir.join(".git").exists())73
+ .map(Path::to_path_buf)74
+}75
+76
+pub fn project_config_file(cwd: &Path) -> Option<PathBuf> {77
+ let root = git_root(cwd)?;78
+ for dir in cwd.ancestors() {79
+ if dir.join(".resrc").is_file() {80
+ return Some(dir.join(".resrc"));81
+ }82
+ if dir == root {83
+ break;84
+ }85
+ }86
+ None87
+}88
+7089
pub fn project_root(cwd: &Path) -> PathBuf {7190
const MARKERS: [&str; 6] = [7291
".jj",@@ -87,3 +106,48 @@ pub fn project_root(cwd: &Path) -> PathBuf {
87106
}88107
best.unwrap_or_else(|| cwd.to_path_buf())89108
}109
+110
+#[cfg(test)]111
+mod tests {112
+ use std::{113
+ fs,114
+ path::PathBuf,115
+ time::{SystemTime, UNIX_EPOCH},116
+ };117
+118
+ use super::project_config_file;119
+120
+ fn temp_tree() -> PathBuf {121
+ let stamp = SystemTime::now()122
+ .duration_since(UNIX_EPOCH)123
+ .expect("clock before epoch")124
+ .as_nanos();125
+ let root = std::env::temp_dir().join(format!("roundabout-paths-{stamp}"));126
+ fs::create_dir_all(&root).expect("create temporary tree");127
+ root128
+ }129
+130
+ #[test]131
+ fn project_config_is_nearest_and_git_bounded() {132
+ let outer = temp_tree();133
+ let root = outer.join("repo");134
+ fs::create_dir(&root).expect("create repository tree");135
+ let child = root.join("child");136
+ let nested = child.join("nested");137
+ fs::create_dir_all(&nested).expect("create nested tree");138
+ fs::create_dir(root.join(".git")).expect("create git marker");139
+ fs::write(root.join(".resrc"), "").expect("write root config");140
+ fs::write(child.join(".resrc"), "").expect("write child config");141
+ fs::write(nested.join(".resrc"), "").expect("write nested config");142
+ assert_eq!(project_config_file(&nested), Some(nested.join(".resrc")));143
+ fs::remove_file(nested.join(".resrc")).expect("remove nested config");144
+ assert_eq!(project_config_file(&nested), Some(child.join(".resrc")));145
+ fs::remove_file(child.join(".resrc")).expect("remove child config");146
+ assert_eq!(project_config_file(&nested), Some(root.join(".resrc")));147
+ fs::remove_file(root.join(".resrc")).expect("remove root config");148
+ fs::write(outer.join(".resrc"), "").expect("write outside config");149
+ assert_eq!(project_config_file(&nested), None);150
+ fs::remove_file(outer.join(".resrc")).expect("remove outside config");151
+ fs::remove_dir_all(outer).expect("remove temporary tree");152
+ }153
+}