1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{CruiseError, Result};
/// Application-level configuration persisted at `~/.config/cruise/config.json`.
///
/// This is distinct from per-workflow YAML configs (which live in `~/.cruise/sessions/`).
///
/// ## Behaviour
/// - Missing file -> returns [`AppConfig::default()`].
/// - Invalid JSON or invalid field values -> returns a clear error (never silently clamped).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
/// Maximum number of sessions to execute concurrently in `run --all` mode.
///
/// Must be >= 1. Defaults to `1` (preserves backward-compatible sequential behaviour).
#[serde(alias = "run_all_parallelism")]
pub run_all_parallelism: usize,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
run_all_parallelism: 1,
}
}
}
impl AppConfig {
/// Return the canonical path to the app config file: `$HOME/.config/cruise/config.json`.
///
/// # Errors
///
/// Returns an error if the home directory cannot be determined.
pub fn config_path() -> Result<PathBuf> {
let home = home::home_dir()
.ok_or_else(|| CruiseError::Other("cannot determine home directory".to_string()))?;
Ok(home.join(".config").join("cruise").join("config.json"))
}
/// Load the app config using the canonical [`Self::config_path`].
///
/// # Errors
///
/// - Returns an error if the home directory cannot be determined.
/// - Returns an error if the file exists but contains invalid JSON or invalid values.
pub fn load() -> Result<Self> {
let path = Self::config_path()?;
Self::load_from(&path)
}
/// Load the app config from an explicit path (useful in tests and alternative config locations).
///
/// - File absent -> returns [`AppConfig::default()`].
/// - File present but invalid -> returns an error.
///
/// # Errors
///
/// Returns an error if the file exists but cannot be read or parsed, or if validation fails.
pub fn load_from(path: &Path) -> Result<Self> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => {
return Err(CruiseError::Other(format!(
"failed to read config {}: {e}",
path.display()
)));
}
};
let config: Self = serde_json::from_str(&content).map_err(|e| {
CruiseError::Other(format!("invalid config JSON in {}: {e}", path.display()))
})?;
config.validate()?;
Ok(config)
}
/// Persist the app config to disk using the canonical [`Self::config_path`].
///
/// # Errors
///
/// Returns an error if the home directory cannot be determined or the file cannot be written.
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
self.save_to(&path)
}
/// Persist the app config to an explicit path.
///
/// Creates parent directories as needed. The write uses a temp-file-then-rename pattern
/// for atomicity on platforms that support it.
///
/// # Errors
///
/// Returns an error if the file cannot be written or if the config is invalid.
pub fn save_to(&self, path: &Path) -> Result<()> {
self.validate()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
CruiseError::Other(format!(
"failed to create config dir {}: {e}",
parent.display()
))
})?;
}
// Write to a temp file in the same directory then rename for atomicity.
let tmp_path = path.with_extension("json.tmp");
let content = serde_json::to_string_pretty(self)
.map_err(|e| CruiseError::Other(format!("failed to serialize config: {e}")))?;
std::fs::write(&tmp_path, content).map_err(|e| {
CruiseError::Other(format!(
"failed to write config to {}: {e}",
tmp_path.display()
))
})?;
if let Err(e) = std::fs::rename(&tmp_path, path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(CruiseError::Other(format!(
"failed to rename config file: {e}"
)));
}
Ok(())
}
/// Validate the config, returning a clear error for any invalid field.
///
/// `run_all_parallelism` must be >= 1. A value of `0` is never silently clamped.
///
/// # Errors
///
/// Returns [`CruiseError::Other`] if any field is invalid.
pub fn validate(&self) -> Result<()> {
if self.run_all_parallelism == 0 {
return Err(CruiseError::Other(
"run_all_parallelism must be >= 1 (got 0)".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
// -- Helpers --------------------------------------------------------------
/// Write `content` to `<dir>/.config/cruise/config.json`, creating parent dirs.
/// Returns the path to the written file.
fn write_config_file(dir: &Path, content: &str) -> PathBuf {
let config_dir = dir.join(".config").join("cruise");
std::fs::create_dir_all(&config_dir).unwrap_or_else(|e| panic!("{e}"));
let config_path = config_dir.join("config.json");
std::fs::write(&config_path, content).unwrap_or_else(|e| panic!("{e}"));
config_path
}
// -- AppConfig::default() --------------------------------------------------
#[test]
fn test_default_parallelism_is_one() {
// Given/When: a default AppConfig
let config = AppConfig::default();
// Then: run_all_parallelism is 1 -- backward-compatible sequential behaviour
assert_eq!(config.run_all_parallelism, 1);
}
// -- AppConfig::validate() ------------------------------------------------
#[test]
fn test_validate_accepts_parallelism_of_one() {
// Given: minimum valid parallelism
let config = AppConfig {
run_all_parallelism: 1,
};
// When/Then: no error
assert!(config.validate().is_ok(), "parallelism=1 should be valid");
}
#[test]
fn test_validate_accepts_large_parallelism() {
// Given: large valid parallelism
let config = AppConfig {
run_all_parallelism: 64,
};
// When/Then: no error
assert!(config.validate().is_ok(), "parallelism=64 should be valid");
}
#[test]
fn test_validate_rejects_zero_parallelism() {
// Given: parallelism of 0 -- explicitly invalid
let config = AppConfig {
run_all_parallelism: 0,
};
// When: validate
let result = config.validate();
// Then: returns a clear error -- must NOT be silently clamped
assert!(
result.is_err(),
"expected error for run_all_parallelism=0, got Ok"
);
let msg = match result {
Err(e) => e.to_string(),
Ok(()) => unreachable!("already asserted is_err above"),
};
assert!(
msg.contains("parallelism") || msg.contains('0'),
"error message should mention the invalid value, got: {msg}"
);
}
// -- AppConfig::config_path() ----------------------------------------------
#[test]
fn test_config_path_ends_with_config_cruise_config_json() {
// When: the canonical config path is resolved
let path = AppConfig::config_path().unwrap_or_else(|e| panic!("expected Ok, got: {e}"));
// Then: path contains .config/cruise/config.json (OS-independent check)
let components: Vec<_> = path.components().collect();
let path_str = path.to_string_lossy();
assert!(
path_str.contains("config.json"),
"expected path to end with config.json, got: {path_str}"
);
assert!(
components
.windows(2)
.any(|w| w[0].as_os_str() == "cruise" && w[1].as_os_str() == "config.json"),
"expected cruise/config.json in path, got: {path_str}"
);
}
// -- AppConfig::load_from() ------------------------------------------------
#[test]
fn test_load_from_returns_defaults_when_file_absent() {
// Given: a path that does not exist
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let missing = tmp.path().join("nonexistent").join("config.json");
// When: load_from
let config =
AppConfig::load_from(&missing).unwrap_or_else(|e| panic!("expected Ok, got: {e}"));
// Then: returns defaults (not an error)
assert_eq!(
config,
AppConfig::default(),
"absent file should yield defaults"
);
}
#[test]
fn test_load_from_returns_error_for_invalid_json() {
// Given: a config file with malformed JSON
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let config_path = write_config_file(tmp.path(), "not valid json at all");
// When: load_from
let result = AppConfig::load_from(&config_path);
// Then: returns an error -- not silently ignored or defaulted
assert!(result.is_err(), "expected error for invalid JSON, got Ok");
}
#[test]
fn test_load_from_returns_error_for_empty_json_object() {
// Given: {} is valid JSON but missing required fields -- depends on serde defaults
// The expected behaviour is to fill in the default. An explicit {} should work.
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let config_path = write_config_file(tmp.path(), "{}");
// When: load_from -- {} has no run_all_parallelism; serde may use field default
// Then: should succeed (serde can apply field-level default) or error gracefully --
// never panic. Either outcome is acceptable; we just verify no panic.
let _result = AppConfig::load_from(&config_path);
// (no assertion on Ok/Err; just verifying the call completes safely)
}
#[test]
fn test_load_from_returns_error_for_zero_parallelism_in_file() {
// Given: a config file with run_all_parallelism = 0 -- explicitly invalid
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let config_path = write_config_file(tmp.path(), r#"{"run_all_parallelism": 0}"#);
// When: load_from
let result = AppConfig::load_from(&config_path);
// Then: returns a clear error -- must NOT silently clamp to 1
assert!(
result.is_err(),
"expected error for run_all_parallelism=0, got Ok"
);
}
#[test]
fn test_load_from_parses_valid_parallelism() {
// Given: a valid config file with run_all_parallelism = 4
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let config_path = write_config_file(tmp.path(), r#"{"run_all_parallelism": 4}"#);
// When: load_from
let config =
AppConfig::load_from(&config_path).unwrap_or_else(|e| panic!("expected Ok, got: {e}"));
// Then: parallelism matches the file
assert_eq!(config.run_all_parallelism, 4);
}
// -- AppConfig::save_to() + load_from() round-trip ------------------------
#[test]
fn test_save_to_then_load_from_round_trips_parallelism() {
// Given: a non-default AppConfig
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let path = tmp.path().join("config.json");
let original = AppConfig {
run_all_parallelism: 8,
};
// When: save then load
original
.save_to(&path)
.unwrap_or_else(|e| panic!("save failed: {e}"));
let loaded = AppConfig::load_from(&path).unwrap_or_else(|e| panic!("load failed: {e}"));
// Then: round-trip preserves the value exactly
assert_eq!(loaded, original);
}
#[test]
fn test_save_to_creates_parent_directories() {
// Given: a deeply nested path whose parents do not yet exist
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let path = tmp.path().join("a").join("b").join("c").join("config.json");
let config = AppConfig {
run_all_parallelism: 2,
};
// When: save_to
config
.save_to(&path)
.unwrap_or_else(|e| panic!("save failed: {e}"));
// Then: the file now exists
assert!(path.exists(), "config file should have been created");
}
#[test]
fn test_save_to_overwrites_existing_file() {
// Given: a file with one value already written
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let path = tmp.path().join("config.json");
AppConfig {
run_all_parallelism: 3,
}
.save_to(&path)
.unwrap_or_else(|e| panic!("{e}"));
// When: save a different value
AppConfig {
run_all_parallelism: 7,
}
.save_to(&path)
.unwrap_or_else(|e| panic!("{e}"));
// Then: the file contains the new value
let loaded = AppConfig::load_from(&path).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(loaded.run_all_parallelism, 7);
}
#[test]
fn test_save_to_does_not_persist_invalid_config() {
// Given: a config that fails validation (parallelism = 0)
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let path = tmp.path().join("config.json");
let invalid = AppConfig {
run_all_parallelism: 0,
};
// When: save_to
let result = invalid.save_to(&path);
// Then: returns an error; the file should not have been created (or is unusable)
assert!(
result.is_err(),
"save_to should reject invalid config, got Ok"
);
}
}