compass-st 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/VERSION +1 -0
- package/bin/install +174 -0
- package/bootstrap.sh +95 -0
- package/cli/Cargo.lock +270 -0
- package/cli/Cargo.toml +24 -0
- package/cli/src/cmd/context.rs +59 -0
- package/cli/src/cmd/dag.rs +133 -0
- package/cli/src/cmd/git.rs +148 -0
- package/cli/src/cmd/hook.rs +51 -0
- package/cli/src/cmd/index.rs +363 -0
- package/cli/src/cmd/manifest.rs +34 -0
- package/cli/src/cmd/memory.rs +680 -0
- package/cli/src/cmd/migrate.rs +790 -0
- package/cli/src/cmd/mod.rs +14 -0
- package/cli/src/cmd/progress.rs +107 -0
- package/cli/src/cmd/project.rs +1700 -0
- package/cli/src/cmd/session.rs +64 -0
- package/cli/src/cmd/state.rs +317 -0
- package/cli/src/cmd/validate/mod.rs +506 -0
- package/cli/src/cmd/validate/prd.rs +472 -0
- package/cli/src/cmd/version.rs +89 -0
- package/cli/src/helpers.rs +40 -0
- package/cli/src/main.rs +75 -0
- package/cli/tests/fixtures/plan_empty_pointers.json +60 -0
- package/cli/tests/fixtures/plan_missing_pointers.json +59 -0
- package/cli/tests/fixtures/plan_too_many_pointers.json +92 -0
- package/cli/tests/fixtures/plan_v1_valid.json +64 -0
- package/cli/tests/fixtures/prd_bad_flow_bullet.md +37 -0
- package/cli/tests/fixtures/prd_bad_flow_prose.md +33 -0
- package/cli/tests/fixtures/prd_good_flow.md +41 -0
- package/cli/tests/fixtures/prd_xref_dangling.md +38 -0
- package/cli/tests/fixtures/prd_xref_valid.md +53 -0
- package/cli/tests/fixtures/projects/proj_a/.compass/.state/config.json +12 -0
- package/cli/tests/fixtures/projects/proj_b/.compass/.state/config.json +12 -0
- package/cli/tests/fixtures/projects/proj_c/.compass/.state/config.json +12 -0
- package/cli/tests/fixtures/registry/all_dead.json +18 -0
- package/cli/tests/fixtures/registry/corrupt.json +1 -0
- package/cli/tests/fixtures/registry/empty.json +1 -0
- package/cli/tests/fixtures/registry/last_active_dead.json +24 -0
- package/cli/tests/fixtures/registry/multi_alive.json +24 -0
- package/cli/tests/fixtures/registry/one_alive.json +12 -0
- package/cli/tests/fixtures/v0_project/.compass/.state/config.json +5 -0
- package/cli/tests/fixtures/v0_project/.compass/.state/sessions/onboarding-redesign/plan.json +29 -0
- package/cli/tests/fixtures/v0_project/.compass/.state/sessions/sample-feature/context.json +11 -0
- package/cli/tests/fixtures/v0_project/.compass/.state/sessions/sample-feature/plan.json +49 -0
- package/core/colleagues/base-rules.md +112 -0
- package/core/colleagues/manifest.json +85 -0
- package/core/colleagues/market-analyst.md +50 -0
- package/core/colleagues/prioritizer.md +53 -0
- package/core/colleagues/researcher.md +54 -0
- package/core/colleagues/reviewer.md +55 -0
- package/core/colleagues/stakeholder-comm.md +59 -0
- package/core/colleagues/story-breaker.md +57 -0
- package/core/colleagues/ux-reviewer.md +54 -0
- package/core/colleagues/writer.md +55 -0
- package/core/commands/compass/brief.md +28 -0
- package/core/commands/compass/check.md +27 -0
- package/core/commands/compass/epic.md +32 -0
- package/core/commands/compass/feedback.md +32 -0
- package/core/commands/compass/help.md +24 -0
- package/core/commands/compass/ideate.md +32 -0
- package/core/commands/compass/init.md +30 -0
- package/core/commands/compass/plan.md +27 -0
- package/core/commands/compass/prd.md +39 -0
- package/core/commands/compass/prioritize.md +36 -0
- package/core/commands/compass/prototype.md +28 -0
- package/core/commands/compass/release.md +32 -0
- package/core/commands/compass/research.md +31 -0
- package/core/commands/compass/roadmap.md +32 -0
- package/core/commands/compass/run.md +28 -0
- package/core/commands/compass/setup.md +32 -0
- package/core/commands/compass/sprint.md +32 -0
- package/core/commands/compass/status.md +32 -0
- package/core/commands/compass/story.md +37 -0
- package/core/commands/compass/undo.md +33 -0
- package/core/commands/compass/update.md +29 -0
- package/core/hooks/context-monitor.sh +5 -0
- package/core/hooks/manifest-tracker.sh +62 -0
- package/core/hooks/statusline.sh +12 -0
- package/core/hooks/update-checker.sh +24 -0
- package/core/integrations/confluence.md +267 -0
- package/core/integrations/figma.md +277 -0
- package/core/integrations/jira.md +436 -0
- package/core/integrations/vercel.md +170 -0
- package/core/manifest.json +172 -0
- package/core/shared/SCHEMAS-v1.md +404 -0
- package/core/shared/progress.md +145 -0
- package/core/shared/project-scan.md +293 -0
- package/core/shared/resolve-project.md +136 -0
- package/core/shared/ux-rules.md +52 -0
- package/core/shared/version-backup.md +38 -0
- package/core/templates/prd-template.md +145 -0
- package/core/templates/story-template.md +99 -0
- package/core/workflows/brief.md +184 -0
- package/core/workflows/check.md +436 -0
- package/core/workflows/epic.md +177 -0
- package/core/workflows/feedback.md +164 -0
- package/core/workflows/help.md +79 -0
- package/core/workflows/ideate.md +320 -0
- package/core/workflows/init.md +524 -0
- package/core/workflows/migrate.md +136 -0
- package/core/workflows/plan.md +320 -0
- package/core/workflows/prd.md +632 -0
- package/core/workflows/prioritize.md +301 -0
- package/core/workflows/project.md +177 -0
- package/core/workflows/prototype.md +174 -0
- package/core/workflows/release.md +179 -0
- package/core/workflows/research.md +613 -0
- package/core/workflows/roadmap.md +152 -0
- package/core/workflows/run.md +367 -0
- package/core/workflows/setup.md +294 -0
- package/core/workflows/sprint.md +187 -0
- package/core/workflows/status.md +185 -0
- package/core/workflows/story.md +477 -0
- package/core/workflows/undo.md +42 -0
- package/core/workflows/update.md +127 -0
- package/package.json +37 -0
|
@@ -0,0 +1,1700 @@
|
|
|
1
|
+
use fs2::FileExt;
|
|
2
|
+
use serde_json::{json, Value};
|
|
3
|
+
use std::fs::{self, OpenOptions};
|
|
4
|
+
use std::path::{Path, PathBuf};
|
|
5
|
+
use std::time::SystemTime;
|
|
6
|
+
|
|
7
|
+
const REGISTRY_VERSION: &str = "1.0";
|
|
8
|
+
const GLOBAL_CONFIG_VERSION: &str = "1.0";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Dispatch
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
pub fn run(args: &[String]) -> Result<String, String> {
|
|
15
|
+
if args.is_empty() {
|
|
16
|
+
return Err(usage());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
match args[0].as_str() {
|
|
20
|
+
"--help" | "-h" | "help" => Ok(usage()),
|
|
21
|
+
"resolve" => resolve(),
|
|
22
|
+
"list" => list(),
|
|
23
|
+
"use" => {
|
|
24
|
+
let path = args
|
|
25
|
+
.get(1)
|
|
26
|
+
.ok_or_else(|| "Usage: compass-cli project use <path>".to_string())?;
|
|
27
|
+
use_project(path)
|
|
28
|
+
}
|
|
29
|
+
"add" => {
|
|
30
|
+
let path = args
|
|
31
|
+
.get(1)
|
|
32
|
+
.ok_or_else(|| "Usage: compass-cli project add <path>".to_string())?;
|
|
33
|
+
add(path)
|
|
34
|
+
}
|
|
35
|
+
"remove" => {
|
|
36
|
+
let path = args
|
|
37
|
+
.get(1)
|
|
38
|
+
.ok_or_else(|| "Usage: compass-cli project remove <path>".to_string())?;
|
|
39
|
+
remove(path)
|
|
40
|
+
}
|
|
41
|
+
"global-config" => global_config(&args[1..]),
|
|
42
|
+
other => Err(format!("Unknown project command: {}. Run 'compass-cli project --help' for usage.", other)),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn usage() -> String {
|
|
47
|
+
"Usage: compass-cli project <resolve|list|use|add|remove|global-config> [...]".into()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fn parse_flag(args: &[String], flag: &str) -> Option<String> {
|
|
51
|
+
args.iter()
|
|
52
|
+
.position(|a| a == flag)
|
|
53
|
+
.and_then(|i| args.get(i + 1).cloned())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Paths & timestamp helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
fn home_dir() -> PathBuf {
|
|
61
|
+
std::env::var_os("HOME")
|
|
62
|
+
.map(PathBuf::from)
|
|
63
|
+
.unwrap_or_else(|| PathBuf::from("/"))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn compass_dir() -> PathBuf {
|
|
67
|
+
home_dir().join(".compass")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn registry_path() -> PathBuf {
|
|
71
|
+
compass_dir().join("projects.json")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fn global_config_path() -> PathBuf {
|
|
75
|
+
compass_dir().join("global-config.json")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fn ensure_compass_dir() -> Result<(), String> {
|
|
79
|
+
let dir = compass_dir();
|
|
80
|
+
fs::create_dir_all(&dir)
|
|
81
|
+
.map_err(|e| format!("Cannot create {}: {}", dir.display(), e))?;
|
|
82
|
+
// Registry + global-config hold user-level state (list of projects, prefs).
|
|
83
|
+
// Tighten perms to owner-only so the file is not world-readable on shared
|
|
84
|
+
// hosts. Unix-only; Windows ignores the mode.
|
|
85
|
+
#[cfg(unix)]
|
|
86
|
+
{
|
|
87
|
+
use std::os::unix::fs::PermissionsExt;
|
|
88
|
+
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
|
89
|
+
}
|
|
90
|
+
Ok(())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fn project_config_path(project_root: &Path) -> PathBuf {
|
|
94
|
+
project_root
|
|
95
|
+
.join(".compass")
|
|
96
|
+
.join(".state")
|
|
97
|
+
.join("config.json")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn now_iso() -> String {
|
|
101
|
+
let secs = SystemTime::now()
|
|
102
|
+
.duration_since(SystemTime::UNIX_EPOCH)
|
|
103
|
+
.map(|d| d.as_secs())
|
|
104
|
+
.unwrap_or(0);
|
|
105
|
+
format_iso_utc(secs)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn format_iso_utc(total_secs: u64) -> String {
|
|
109
|
+
let days = (total_secs / 86_400) as i64;
|
|
110
|
+
let tod = total_secs % 86_400;
|
|
111
|
+
let (y, m, d) = civil_from_days(days);
|
|
112
|
+
let hh = tod / 3600;
|
|
113
|
+
let mm = (tod % 3600) / 60;
|
|
114
|
+
let ss = tod % 60;
|
|
115
|
+
format!(
|
|
116
|
+
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
|
|
117
|
+
y, m, d, hh, mm, ss
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
|
122
|
+
let z = z + 719_468;
|
|
123
|
+
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
124
|
+
let doe = (z - era * 146_097) as u64;
|
|
125
|
+
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
|
126
|
+
let y = yoe as i64 + era * 400;
|
|
127
|
+
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
128
|
+
let mp = (5 * doy + 2) / 153;
|
|
129
|
+
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
130
|
+
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
|
131
|
+
(y + if m <= 2 { 1 } else { 0 }, m, d)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Canonicalize a user-supplied path. Falls back when `canonicalize` fails
|
|
135
|
+
/// (e.g. path does not exist yet): walk up ancestors finding the nearest
|
|
136
|
+
/// existing parent, canonicalize THAT, then re-attach the missing tail. This
|
|
137
|
+
/// resolves `..` components and symlinks even for not-yet-created paths, so
|
|
138
|
+
/// registry entries are stable regardless of how the user spelled the path
|
|
139
|
+
/// (e.g. macOS `/var` vs `/private/var`).
|
|
140
|
+
fn canonicalize_path(p: &str) -> PathBuf {
|
|
141
|
+
let raw = Path::new(p);
|
|
142
|
+
if let Ok(c) = fs::canonicalize(raw) {
|
|
143
|
+
return strip_trailing_slash(&c);
|
|
144
|
+
}
|
|
145
|
+
// Fallback: find nearest existing ancestor, canonicalize it, append tail.
|
|
146
|
+
let base = if raw.is_absolute() {
|
|
147
|
+
raw.to_path_buf()
|
|
148
|
+
} else {
|
|
149
|
+
std::env::current_dir()
|
|
150
|
+
.map(|c| c.join(raw))
|
|
151
|
+
.unwrap_or_else(|_| raw.to_path_buf())
|
|
152
|
+
};
|
|
153
|
+
let mut ancestor = base.clone();
|
|
154
|
+
let mut tail_components: Vec<std::ffi::OsString> = Vec::new();
|
|
155
|
+
while !ancestor.exists() {
|
|
156
|
+
match (ancestor.parent(), ancestor.file_name()) {
|
|
157
|
+
(Some(parent), Some(name)) => {
|
|
158
|
+
tail_components.push(name.to_os_string());
|
|
159
|
+
ancestor = parent.to_path_buf();
|
|
160
|
+
}
|
|
161
|
+
_ => break, // hit root
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
let canonical_base = fs::canonicalize(&ancestor).unwrap_or(ancestor);
|
|
165
|
+
let mut out = canonical_base;
|
|
166
|
+
for comp in tail_components.iter().rev() {
|
|
167
|
+
// Manually resolve `..` vs real names on the synthesized tail.
|
|
168
|
+
if comp == std::ffi::OsStr::new("..") {
|
|
169
|
+
if let Some(p) = out.parent() {
|
|
170
|
+
out = p.to_path_buf();
|
|
171
|
+
}
|
|
172
|
+
} else if comp != std::ffi::OsStr::new(".") {
|
|
173
|
+
out.push(comp);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
strip_trailing_slash(&out)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn strip_trailing_slash(p: &Path) -> PathBuf {
|
|
180
|
+
let mut out = PathBuf::new();
|
|
181
|
+
for comp in p.components() {
|
|
182
|
+
out.push(comp.as_os_str());
|
|
183
|
+
}
|
|
184
|
+
out
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fn cwd_string() -> String {
|
|
188
|
+
std::env::current_dir()
|
|
189
|
+
.map(|p| p.to_string_lossy().to_string())
|
|
190
|
+
.unwrap_or_else(|_| ".".to_string())
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Registry I/O
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/// Load the registry, handling three cases:
|
|
198
|
+
/// - file missing → empty registry
|
|
199
|
+
/// - file present but unparseable → back up to `.bak`, reset to empty, warn stderr
|
|
200
|
+
/// - file present and parseable → return it
|
|
201
|
+
fn load_registry_or_reset() -> Value {
|
|
202
|
+
let path = registry_path();
|
|
203
|
+
if !path.exists() {
|
|
204
|
+
return empty_registry();
|
|
205
|
+
}
|
|
206
|
+
let raw = match fs::read_to_string(&path) {
|
|
207
|
+
Ok(s) => s,
|
|
208
|
+
Err(e) => {
|
|
209
|
+
eprintln!("warn: cannot read {}: {}", path.display(), e);
|
|
210
|
+
return empty_registry();
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
match serde_json::from_str::<Value>(&raw) {
|
|
214
|
+
Ok(v) => {
|
|
215
|
+
if v.is_object() {
|
|
216
|
+
v
|
|
217
|
+
} else {
|
|
218
|
+
eprintln!(
|
|
219
|
+
"warn: {} is not a JSON object; resetting",
|
|
220
|
+
path.display()
|
|
221
|
+
);
|
|
222
|
+
backup_and_reset(&path, &raw);
|
|
223
|
+
empty_registry()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
Err(e) => {
|
|
227
|
+
eprintln!(
|
|
228
|
+
"warn: CORRUPT_REGISTRY at {} ({}); backing up to .bak and resetting",
|
|
229
|
+
path.display(),
|
|
230
|
+
e
|
|
231
|
+
);
|
|
232
|
+
backup_and_reset(&path, &raw);
|
|
233
|
+
empty_registry()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn backup_and_reset(path: &Path, raw: &str) {
|
|
239
|
+
let bak = path.with_extension("json.bak");
|
|
240
|
+
let _ = fs::write(&bak, raw);
|
|
241
|
+
let _ = fs::write(path, empty_registry().to_string());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
fn empty_registry() -> Value {
|
|
245
|
+
json!({
|
|
246
|
+
"version": REGISTRY_VERSION,
|
|
247
|
+
"last_active": Value::Null,
|
|
248
|
+
"projects": [],
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/// Write the registry atomically (tmp + rename) under an exclusive file lock.
|
|
253
|
+
/// Creates `~/.compass/` if missing.
|
|
254
|
+
fn write_registry(value: &Value) -> Result<(), String> {
|
|
255
|
+
ensure_compass_dir()?;
|
|
256
|
+
let path = registry_path();
|
|
257
|
+
|
|
258
|
+
// Touch-ensure the target file exists so we can lock it.
|
|
259
|
+
if !path.exists() {
|
|
260
|
+
fs::write(&path, "{}")
|
|
261
|
+
.map_err(|e| format!("Cannot create {}: {}", path.display(), e))?;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let file = OpenOptions::new()
|
|
265
|
+
.read(true)
|
|
266
|
+
.write(true)
|
|
267
|
+
.open(&path)
|
|
268
|
+
.map_err(|e| format!("Cannot open {}: {}", path.display(), e))?;
|
|
269
|
+
file.lock_exclusive()
|
|
270
|
+
.map_err(|e| format!("Cannot lock {}: {}", path.display(), e))?;
|
|
271
|
+
|
|
272
|
+
let result = (|| -> Result<(), String> {
|
|
273
|
+
let tmp = path.with_extension("json.tmp");
|
|
274
|
+
let body = serde_json::to_string_pretty(value)
|
|
275
|
+
.map_err(|e| format!("JSON serialize error: {}", e))?;
|
|
276
|
+
fs::write(&tmp, &body)
|
|
277
|
+
.map_err(|e| format!("Cannot write {}: {}", tmp.display(), e))?;
|
|
278
|
+
fs::rename(&tmp, &path)
|
|
279
|
+
.map_err(|e| format!("Cannot rename {} → {}: {}", tmp.display(), path.display(), e))?;
|
|
280
|
+
Ok(())
|
|
281
|
+
})();
|
|
282
|
+
|
|
283
|
+
let _ = file.unlock();
|
|
284
|
+
result
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// Sort `projects[]` by `last_used` desc in-place.
|
|
288
|
+
fn sort_projects_desc(reg: &mut Value) {
|
|
289
|
+
if let Some(arr) = reg
|
|
290
|
+
.get_mut("projects")
|
|
291
|
+
.and_then(|v| v.as_array_mut())
|
|
292
|
+
{
|
|
293
|
+
arr.sort_by(|a, b| {
|
|
294
|
+
let la = a.get("last_used").and_then(|v| v.as_str()).unwrap_or("");
|
|
295
|
+
let lb = b.get("last_used").and_then(|v| v.as_str()).unwrap_or("");
|
|
296
|
+
lb.cmp(la)
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fn projects_array(reg: &Value) -> Vec<Value> {
|
|
302
|
+
reg.get("projects")
|
|
303
|
+
.and_then(|v| v.as_array())
|
|
304
|
+
.cloned()
|
|
305
|
+
.unwrap_or_default()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
fn set_projects(reg: &mut Value, projects: Vec<Value>) {
|
|
309
|
+
if let Some(obj) = reg.as_object_mut() {
|
|
310
|
+
obj.insert("projects".to_string(), Value::Array(projects));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
fn last_active(reg: &Value) -> Option<String> {
|
|
315
|
+
reg.get("last_active")
|
|
316
|
+
.and_then(|v| v.as_str())
|
|
317
|
+
.map(|s| s.to_string())
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
fn set_last_active(reg: &mut Value, value: Option<&str>) {
|
|
321
|
+
if let Some(obj) = reg.as_object_mut() {
|
|
322
|
+
obj.insert(
|
|
323
|
+
"last_active".to_string(),
|
|
324
|
+
match value {
|
|
325
|
+
Some(s) => Value::String(s.to_string()),
|
|
326
|
+
None => Value::Null,
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
fn is_alive(project_path: &str) -> bool {
|
|
333
|
+
project_config_path(Path::new(project_path)).exists()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
fn prune_dead(reg: &mut Value) -> Vec<String> {
|
|
337
|
+
let projects = projects_array(reg);
|
|
338
|
+
let mut alive = Vec::new();
|
|
339
|
+
let mut dead = Vec::new();
|
|
340
|
+
for p in projects {
|
|
341
|
+
let path = p.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
|
342
|
+
if path.is_empty() {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if is_alive(path) {
|
|
346
|
+
alive.push(p);
|
|
347
|
+
} else {
|
|
348
|
+
dead.push(path.to_string());
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
set_projects(reg, alive);
|
|
352
|
+
|
|
353
|
+
// If last_active references a dead entry, clear it.
|
|
354
|
+
if let Some(la) = last_active(reg) {
|
|
355
|
+
if dead.iter().any(|d| d == &la) || !is_alive(&la) {
|
|
356
|
+
set_last_active(reg, None);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
dead
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// `resolve`
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
fn resolve() -> Result<String, String> {
|
|
368
|
+
let cwd = cwd_string();
|
|
369
|
+
let mut reg = load_registry_or_reset();
|
|
370
|
+
let mut migrated = false;
|
|
371
|
+
let mut dirty = false;
|
|
372
|
+
|
|
373
|
+
// If registry empty and cwd is a compass project → auto-migrate (REQ-10).
|
|
374
|
+
let starting_empty = projects_array(®).is_empty();
|
|
375
|
+
if starting_empty {
|
|
376
|
+
let cwd_path = Path::new(&cwd);
|
|
377
|
+
if project_config_path(cwd_path).exists() {
|
|
378
|
+
let abs = canonicalize_path(&cwd);
|
|
379
|
+
let abs_str = abs.to_string_lossy().to_string();
|
|
380
|
+
let name = read_project_name(&abs).unwrap_or_else(|| "(unknown)".to_string());
|
|
381
|
+
let now = now_iso();
|
|
382
|
+
let entry = json!({
|
|
383
|
+
"path": abs_str,
|
|
384
|
+
"name": name,
|
|
385
|
+
"created_at": now,
|
|
386
|
+
"last_used": now,
|
|
387
|
+
});
|
|
388
|
+
let mut projects = projects_array(®);
|
|
389
|
+
projects.push(entry);
|
|
390
|
+
set_projects(&mut reg, projects);
|
|
391
|
+
set_last_active(&mut reg, Some(&abs_str));
|
|
392
|
+
migrated = true;
|
|
393
|
+
dirty = true;
|
|
394
|
+
} else {
|
|
395
|
+
// Empty and no local config → status: none.
|
|
396
|
+
return Ok(json!({
|
|
397
|
+
"status": "none",
|
|
398
|
+
"reason": "empty_registry",
|
|
399
|
+
"cwd": cwd,
|
|
400
|
+
})
|
|
401
|
+
.to_string());
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Prune dead entries (REQ-11).
|
|
406
|
+
let dead = prune_dead(&mut reg);
|
|
407
|
+
if !dead.is_empty() {
|
|
408
|
+
dirty = true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let remaining = projects_array(®);
|
|
412
|
+
if remaining.is_empty() {
|
|
413
|
+
if dirty {
|
|
414
|
+
let _ = write_registry(®);
|
|
415
|
+
}
|
|
416
|
+
return Ok(json!({
|
|
417
|
+
"status": "none",
|
|
418
|
+
"reason": "all_paths_dead",
|
|
419
|
+
"cwd": cwd,
|
|
420
|
+
})
|
|
421
|
+
.to_string());
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Decide active.
|
|
425
|
+
let active_path = match last_active(®) {
|
|
426
|
+
Some(p) if is_alive(&p) => Some(p),
|
|
427
|
+
_ => {
|
|
428
|
+
// last_active was pruned or never set.
|
|
429
|
+
if remaining.len() == 1 {
|
|
430
|
+
let only = remaining[0]
|
|
431
|
+
.get("path")
|
|
432
|
+
.and_then(|v| v.as_str())
|
|
433
|
+
.map(|s| s.to_string());
|
|
434
|
+
if let Some(p) = &only {
|
|
435
|
+
set_last_active(&mut reg, Some(p));
|
|
436
|
+
dirty = true;
|
|
437
|
+
}
|
|
438
|
+
only
|
|
439
|
+
} else {
|
|
440
|
+
None
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// If cwd has a compass config but isn't registered, warn the user on
|
|
446
|
+
// stderr. Avoid auto-add to keep resolve side-effect-free for this path
|
|
447
|
+
// (migration auto-add only fires when the registry was EMPTY above).
|
|
448
|
+
if !migrated {
|
|
449
|
+
let cwd_path = Path::new(&cwd);
|
|
450
|
+
if project_config_path(cwd_path).exists() {
|
|
451
|
+
let cwd_abs = canonicalize_path(&cwd);
|
|
452
|
+
let cwd_abs_str = cwd_abs.to_string_lossy().to_string();
|
|
453
|
+
let already_registered = projects_array(®).iter().any(|p| {
|
|
454
|
+
p.get("path").and_then(|v| v.as_str()) == Some(cwd_abs_str.as_str())
|
|
455
|
+
});
|
|
456
|
+
if !already_registered {
|
|
457
|
+
eprintln!(
|
|
458
|
+
"note: cwd has an unregistered Compass project at {}. \
|
|
459
|
+
Run 'compass-cli project add {}' to register it, or \
|
|
460
|
+
'compass-cli project use {}' to switch.",
|
|
461
|
+
cwd_abs_str, cwd_abs_str, cwd_abs_str
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
match active_path {
|
|
468
|
+
Some(active) => {
|
|
469
|
+
// Bump last_used for the active entry.
|
|
470
|
+
let now = now_iso();
|
|
471
|
+
{
|
|
472
|
+
let projects = projects_array(®);
|
|
473
|
+
let updated: Vec<Value> = projects
|
|
474
|
+
.into_iter()
|
|
475
|
+
.map(|mut p| {
|
|
476
|
+
if p.get("path").and_then(|v| v.as_str()) == Some(active.as_str()) {
|
|
477
|
+
if let Some(obj) = p.as_object_mut() {
|
|
478
|
+
obj.insert("last_used".to_string(), json!(now));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
p
|
|
482
|
+
})
|
|
483
|
+
.collect();
|
|
484
|
+
set_projects(&mut reg, updated);
|
|
485
|
+
}
|
|
486
|
+
sort_projects_desc(&mut reg);
|
|
487
|
+
let _ = write_registry(®);
|
|
488
|
+
|
|
489
|
+
// Load the project config.
|
|
490
|
+
let config_path = project_config_path(Path::new(&active));
|
|
491
|
+
let config_val = match fs::read_to_string(&config_path)
|
|
492
|
+
.ok()
|
|
493
|
+
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
|
494
|
+
{
|
|
495
|
+
Some(v) => v,
|
|
496
|
+
None => {
|
|
497
|
+
// Alive check passed earlier but file disappeared; fall back to empty object.
|
|
498
|
+
json!({})
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
let name = config_val
|
|
502
|
+
.get("project")
|
|
503
|
+
.and_then(|p| p.get("name"))
|
|
504
|
+
.and_then(|v| v.as_str())
|
|
505
|
+
.unwrap_or("(unknown)")
|
|
506
|
+
.to_string();
|
|
507
|
+
|
|
508
|
+
Ok(json!({
|
|
509
|
+
"status": "ok",
|
|
510
|
+
"project_root": active,
|
|
511
|
+
"name": name,
|
|
512
|
+
"config": config_val,
|
|
513
|
+
"migrated_from_v11": migrated,
|
|
514
|
+
})
|
|
515
|
+
.to_string())
|
|
516
|
+
}
|
|
517
|
+
None => {
|
|
518
|
+
// Ambiguous: >= 2 alive, no last_active.
|
|
519
|
+
sort_projects_desc(&mut reg);
|
|
520
|
+
if dirty {
|
|
521
|
+
let _ = write_registry(®);
|
|
522
|
+
}
|
|
523
|
+
let candidates: Vec<Value> = projects_array(®)
|
|
524
|
+
.into_iter()
|
|
525
|
+
.map(|p| {
|
|
526
|
+
json!({
|
|
527
|
+
"path": p.get("path").cloned().unwrap_or(Value::Null),
|
|
528
|
+
"name": p.get("name").cloned().unwrap_or(Value::Null),
|
|
529
|
+
"last_used": p.get("last_used").cloned().unwrap_or(Value::Null),
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
.collect();
|
|
533
|
+
Ok(json!({
|
|
534
|
+
"status": "ambiguous",
|
|
535
|
+
"candidates": candidates,
|
|
536
|
+
"cwd": cwd,
|
|
537
|
+
})
|
|
538
|
+
.to_string())
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
fn read_project_name(project_root: &Path) -> Option<String> {
|
|
544
|
+
let p = project_config_path(project_root);
|
|
545
|
+
let raw = fs::read_to_string(&p).ok()?;
|
|
546
|
+
let v: Value = serde_json::from_str(&raw).ok()?;
|
|
547
|
+
v.get("project")
|
|
548
|
+
.and_then(|o| o.get("name"))
|
|
549
|
+
.and_then(|n| n.as_str())
|
|
550
|
+
.map(|s| s.to_string())
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// `list`
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
fn list() -> Result<String, String> {
|
|
558
|
+
// Read-only: do NOT prune here.
|
|
559
|
+
let mut reg = load_registry_or_reset();
|
|
560
|
+
sort_projects_desc(&mut reg);
|
|
561
|
+
let active = last_active(®);
|
|
562
|
+
|
|
563
|
+
let rows: Vec<Value> = projects_array(®)
|
|
564
|
+
.into_iter()
|
|
565
|
+
.map(|p| {
|
|
566
|
+
let path = p.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
567
|
+
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
568
|
+
let last_used = p
|
|
569
|
+
.get("last_used")
|
|
570
|
+
.and_then(|v| v.as_str())
|
|
571
|
+
.unwrap_or("")
|
|
572
|
+
.to_string();
|
|
573
|
+
let is_active = active.as_deref() == Some(path.as_str());
|
|
574
|
+
json!({
|
|
575
|
+
"path": path,
|
|
576
|
+
"name": name,
|
|
577
|
+
"last_used": last_used,
|
|
578
|
+
"is_active": is_active,
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
.collect();
|
|
582
|
+
|
|
583
|
+
Ok(Value::Array(rows).to_string())
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
// `use`
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
fn use_project(path_arg: &str) -> Result<String, String> {
|
|
591
|
+
let abs = canonicalize_path(path_arg);
|
|
592
|
+
let abs_str = abs.to_string_lossy().to_string();
|
|
593
|
+
|
|
594
|
+
if !abs.exists() {
|
|
595
|
+
return Ok(json!({
|
|
596
|
+
"ok": false,
|
|
597
|
+
"error": format!("PATH_NOT_FOUND: {}", abs_str),
|
|
598
|
+
})
|
|
599
|
+
.to_string());
|
|
600
|
+
}
|
|
601
|
+
let config_path = project_config_path(&abs);
|
|
602
|
+
if !config_path.exists() {
|
|
603
|
+
return Ok(json!({
|
|
604
|
+
"ok": false,
|
|
605
|
+
"error": format!("NO_CONFIG_AT_PATH: {}", config_path.display()),
|
|
606
|
+
})
|
|
607
|
+
.to_string());
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
let mut reg = load_registry_or_reset();
|
|
611
|
+
let now = now_iso();
|
|
612
|
+
|
|
613
|
+
let mut projects = projects_array(®);
|
|
614
|
+
let present = projects
|
|
615
|
+
.iter()
|
|
616
|
+
.any(|p| p.get("path").and_then(|v| v.as_str()) == Some(abs_str.as_str()));
|
|
617
|
+
|
|
618
|
+
if !present {
|
|
619
|
+
let name = read_project_name(&abs).unwrap_or_else(|| "(unknown)".to_string());
|
|
620
|
+
projects.push(json!({
|
|
621
|
+
"path": abs_str,
|
|
622
|
+
"name": name,
|
|
623
|
+
"created_at": now,
|
|
624
|
+
"last_used": now,
|
|
625
|
+
}));
|
|
626
|
+
} else {
|
|
627
|
+
// Bump last_used.
|
|
628
|
+
for p in projects.iter_mut() {
|
|
629
|
+
if p.get("path").and_then(|v| v.as_str()) == Some(abs_str.as_str()) {
|
|
630
|
+
if let Some(obj) = p.as_object_mut() {
|
|
631
|
+
obj.insert("last_used".to_string(), json!(now));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
set_projects(&mut reg, projects);
|
|
637
|
+
set_last_active(&mut reg, Some(&abs_str));
|
|
638
|
+
sort_projects_desc(&mut reg);
|
|
639
|
+
write_registry(®)?;
|
|
640
|
+
|
|
641
|
+
Ok(json!({
|
|
642
|
+
"ok": true,
|
|
643
|
+
"active": abs_str,
|
|
644
|
+
})
|
|
645
|
+
.to_string())
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// `add`
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
fn add(path_arg: &str) -> Result<String, String> {
|
|
653
|
+
let abs = canonicalize_path(path_arg);
|
|
654
|
+
let abs_str = abs.to_string_lossy().to_string();
|
|
655
|
+
|
|
656
|
+
if !abs.exists() {
|
|
657
|
+
return Err(format!("PATH_NOT_FOUND: {}", abs_str));
|
|
658
|
+
}
|
|
659
|
+
let config_path = project_config_path(&abs);
|
|
660
|
+
if !config_path.exists() {
|
|
661
|
+
return Err(format!("NO_CONFIG_AT_PATH: {}", config_path.display()));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
let mut reg = load_registry_or_reset();
|
|
665
|
+
let mut projects = projects_array(®);
|
|
666
|
+
let present = projects
|
|
667
|
+
.iter()
|
|
668
|
+
.any(|p| p.get("path").and_then(|v| v.as_str()) == Some(abs_str.as_str()));
|
|
669
|
+
if present {
|
|
670
|
+
return Ok(json!({
|
|
671
|
+
"ok": true,
|
|
672
|
+
"path": abs_str,
|
|
673
|
+
"already_present": true,
|
|
674
|
+
})
|
|
675
|
+
.to_string());
|
|
676
|
+
}
|
|
677
|
+
let name = read_project_name(&abs).unwrap_or_else(|| "(unknown)".to_string());
|
|
678
|
+
let now = now_iso();
|
|
679
|
+
projects.push(json!({
|
|
680
|
+
"path": abs_str,
|
|
681
|
+
"name": name,
|
|
682
|
+
"created_at": now,
|
|
683
|
+
"last_used": now,
|
|
684
|
+
}));
|
|
685
|
+
set_projects(&mut reg, projects);
|
|
686
|
+
// Do NOT change last_active on `add`.
|
|
687
|
+
sort_projects_desc(&mut reg);
|
|
688
|
+
write_registry(®)?;
|
|
689
|
+
|
|
690
|
+
Ok(json!({
|
|
691
|
+
"ok": true,
|
|
692
|
+
"path": abs_str,
|
|
693
|
+
"already_present": false,
|
|
694
|
+
})
|
|
695
|
+
.to_string())
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// `remove`
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
fn remove(path_arg: &str) -> Result<String, String> {
|
|
703
|
+
let abs = canonicalize_path(path_arg);
|
|
704
|
+
let abs_str = abs.to_string_lossy().to_string();
|
|
705
|
+
|
|
706
|
+
let mut reg = load_registry_or_reset();
|
|
707
|
+
let before = projects_array(®);
|
|
708
|
+
let after: Vec<Value> = before
|
|
709
|
+
.into_iter()
|
|
710
|
+
.filter(|p| p.get("path").and_then(|v| v.as_str()) != Some(abs_str.as_str()))
|
|
711
|
+
.collect();
|
|
712
|
+
set_projects(&mut reg, after);
|
|
713
|
+
|
|
714
|
+
if last_active(®).as_deref() == Some(abs_str.as_str()) {
|
|
715
|
+
set_last_active(&mut reg, None);
|
|
716
|
+
}
|
|
717
|
+
sort_projects_desc(&mut reg);
|
|
718
|
+
write_registry(®)?;
|
|
719
|
+
|
|
720
|
+
Ok(json!({
|
|
721
|
+
"ok": true,
|
|
722
|
+
"removed": abs_str,
|
|
723
|
+
})
|
|
724
|
+
.to_string())
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
// `global-config`
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
|
|
731
|
+
fn global_config(args: &[String]) -> Result<String, String> {
|
|
732
|
+
let sub = args
|
|
733
|
+
.first()
|
|
734
|
+
.ok_or_else(|| "Usage: compass-cli project global-config <get|set> [...]".to_string())?;
|
|
735
|
+
match sub.as_str() {
|
|
736
|
+
"get" => {
|
|
737
|
+
let key = parse_flag(args, "--key");
|
|
738
|
+
global_config_get(key.as_deref())
|
|
739
|
+
}
|
|
740
|
+
"set" => {
|
|
741
|
+
let key = parse_flag(args, "--key").ok_or_else(|| {
|
|
742
|
+
"Usage: compass-cli project global-config set --key <k> --value <v>".to_string()
|
|
743
|
+
})?;
|
|
744
|
+
let value = parse_flag(args, "--value").ok_or_else(|| {
|
|
745
|
+
"Usage: compass-cli project global-config set --key <k> --value <v>".to_string()
|
|
746
|
+
})?;
|
|
747
|
+
global_config_set(&key, &value)
|
|
748
|
+
}
|
|
749
|
+
other => Err(format!("Unknown global-config command: {}", other)),
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
fn load_global_config() -> Value {
|
|
754
|
+
let path = global_config_path();
|
|
755
|
+
if !path.exists() {
|
|
756
|
+
return json!({});
|
|
757
|
+
}
|
|
758
|
+
match fs::read_to_string(&path)
|
|
759
|
+
.ok()
|
|
760
|
+
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
|
761
|
+
{
|
|
762
|
+
Some(v) => v,
|
|
763
|
+
None => {
|
|
764
|
+
eprintln!(
|
|
765
|
+
"warn: CORRUPT global-config at {}; treating as empty",
|
|
766
|
+
path.display()
|
|
767
|
+
);
|
|
768
|
+
json!({})
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
fn write_global_config(value: &Value) -> Result<(), String> {
|
|
774
|
+
ensure_compass_dir()?;
|
|
775
|
+
let path = global_config_path();
|
|
776
|
+
if !path.exists() {
|
|
777
|
+
fs::write(&path, "{}")
|
|
778
|
+
.map_err(|e| format!("Cannot create {}: {}", path.display(), e))?;
|
|
779
|
+
}
|
|
780
|
+
let file = OpenOptions::new()
|
|
781
|
+
.read(true)
|
|
782
|
+
.write(true)
|
|
783
|
+
.open(&path)
|
|
784
|
+
.map_err(|e| format!("Cannot open {}: {}", path.display(), e))?;
|
|
785
|
+
file.lock_exclusive()
|
|
786
|
+
.map_err(|e| format!("Cannot lock {}: {}", path.display(), e))?;
|
|
787
|
+
|
|
788
|
+
let result = (|| -> Result<(), String> {
|
|
789
|
+
let tmp = path.with_extension("json.tmp");
|
|
790
|
+
let body = serde_json::to_string_pretty(value)
|
|
791
|
+
.map_err(|e| format!("JSON serialize error: {}", e))?;
|
|
792
|
+
fs::write(&tmp, &body)
|
|
793
|
+
.map_err(|e| format!("Cannot write {}: {}", tmp.display(), e))?;
|
|
794
|
+
fs::rename(&tmp, &path)
|
|
795
|
+
.map_err(|e| format!("Cannot rename {} → {}: {}", tmp.display(), path.display(), e))?;
|
|
796
|
+
Ok(())
|
|
797
|
+
})();
|
|
798
|
+
|
|
799
|
+
let _ = file.unlock();
|
|
800
|
+
result
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
fn global_config_get(key: Option<&str>) -> Result<String, String> {
|
|
804
|
+
let data = load_global_config();
|
|
805
|
+
let value = match key {
|
|
806
|
+
Some(k) => lookup_dot_path(&data, k)
|
|
807
|
+
.cloned()
|
|
808
|
+
.unwrap_or(Value::Null),
|
|
809
|
+
None => data,
|
|
810
|
+
};
|
|
811
|
+
serde_json::to_string_pretty(&value).map_err(|e| format!("JSON serialize error: {}", e))
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/// Whitelist of top-level keys allowed in `global-config set`. Anything else
|
|
815
|
+
/// is rejected so a stray key (or path-traversal attempt via dot-path) cannot
|
|
816
|
+
/// pollute the file.
|
|
817
|
+
const ALLOWED_GLOBAL_KEYS: &[&str] = &[
|
|
818
|
+
"lang",
|
|
819
|
+
"default_tech_stack",
|
|
820
|
+
"default_review_style",
|
|
821
|
+
"default_domain",
|
|
822
|
+
];
|
|
823
|
+
|
|
824
|
+
fn global_config_set(key: &str, raw_value: &str) -> Result<String, String> {
|
|
825
|
+
// Reject unknown top-level keys. Dot-paths are allowed only within an
|
|
826
|
+
// allowed root (e.g. `default_tech_stack.0` would traverse an array).
|
|
827
|
+
let top = key.split('.').next().unwrap_or("");
|
|
828
|
+
if !ALLOWED_GLOBAL_KEYS.contains(&top) {
|
|
829
|
+
return Err(format!(
|
|
830
|
+
"INVALID_KEY: '{}' is not an allowed global-config key. Allowed: {:?}",
|
|
831
|
+
key, ALLOWED_GLOBAL_KEYS
|
|
832
|
+
));
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
let mut data = load_global_config();
|
|
836
|
+
|
|
837
|
+
// Auto-create skeleton.
|
|
838
|
+
if !data.is_object() {
|
|
839
|
+
data = json!({});
|
|
840
|
+
}
|
|
841
|
+
{
|
|
842
|
+
let obj = data.as_object_mut().expect("data is object");
|
|
843
|
+
let now = now_iso();
|
|
844
|
+
obj.entry("version")
|
|
845
|
+
.or_insert_with(|| json!(GLOBAL_CONFIG_VERSION));
|
|
846
|
+
obj.entry("created_at")
|
|
847
|
+
.or_insert_with(|| json!(now.clone()));
|
|
848
|
+
obj.insert("updated_at".to_string(), json!(now));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Parse value as JSON; fall back to raw string.
|
|
852
|
+
let parsed: Value = serde_json::from_str(raw_value).unwrap_or_else(|_| json!(raw_value));
|
|
853
|
+
set_dot_path(&mut data, key, parsed);
|
|
854
|
+
|
|
855
|
+
write_global_config(&data)?;
|
|
856
|
+
Ok(json!({
|
|
857
|
+
"ok": true,
|
|
858
|
+
"key": key,
|
|
859
|
+
})
|
|
860
|
+
.to_string())
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
fn lookup_dot_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
|
|
864
|
+
let mut cur = root;
|
|
865
|
+
for seg in path.split('.') {
|
|
866
|
+
if seg.is_empty() {
|
|
867
|
+
return None;
|
|
868
|
+
}
|
|
869
|
+
cur = match cur {
|
|
870
|
+
Value::Object(map) => map.get(seg)?,
|
|
871
|
+
Value::Array(arr) => {
|
|
872
|
+
let idx: usize = seg.parse().ok()?;
|
|
873
|
+
arr.get(idx)?
|
|
874
|
+
}
|
|
875
|
+
_ => return None,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
Some(cur)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
fn set_dot_path(root: &mut Value, path: &str, new_val: Value) {
|
|
882
|
+
let segs: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
|
|
883
|
+
if segs.is_empty() {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
let mut cur = root;
|
|
887
|
+
for (i, seg) in segs.iter().enumerate() {
|
|
888
|
+
let is_last = i == segs.len() - 1;
|
|
889
|
+
if !cur.is_object() {
|
|
890
|
+
*cur = json!({});
|
|
891
|
+
}
|
|
892
|
+
let obj = cur.as_object_mut().unwrap();
|
|
893
|
+
if is_last {
|
|
894
|
+
obj.insert(seg.to_string(), new_val.clone());
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if !obj.contains_key(*seg) {
|
|
898
|
+
obj.insert(seg.to_string(), json!({}));
|
|
899
|
+
}
|
|
900
|
+
cur = obj.get_mut(*seg).unwrap();
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// Test support — shared across modules that mutate $HOME in tests.
|
|
906
|
+
// `#[cfg(test)]` keeps it out of release builds; `#[doc(hidden)]` hides it
|
|
907
|
+
// from `cargo doc`. Visibility stays `pub(crate)` so peer-module test trees
|
|
908
|
+
// (e.g. `cmd::state::tests`) can import the same Mutex — mandatory so cross-
|
|
909
|
+
// module tests serialize on the one process-global `$HOME`.
|
|
910
|
+
// ---------------------------------------------------------------------------
|
|
911
|
+
|
|
912
|
+
#[cfg(test)]
|
|
913
|
+
#[doc(hidden)]
|
|
914
|
+
pub(crate) mod test_support {
|
|
915
|
+
use std::sync::Mutex;
|
|
916
|
+
pub static HOME_GUARD: Mutex<()> = Mutex::new(());
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
// Tests (full unit coverage — T-12)
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
|
|
923
|
+
#[cfg(test)]
|
|
924
|
+
mod tests {
|
|
925
|
+
use super::*;
|
|
926
|
+
use super::test_support::HOME_GUARD;
|
|
927
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
928
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
929
|
+
|
|
930
|
+
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
931
|
+
|
|
932
|
+
fn unique_tmp_dir(tag: &str) -> PathBuf {
|
|
933
|
+
let nanos = SystemTime::now()
|
|
934
|
+
.duration_since(UNIX_EPOCH)
|
|
935
|
+
.map(|d| d.as_nanos())
|
|
936
|
+
.unwrap_or(0);
|
|
937
|
+
let n = TMP_COUNTER.fetch_add(1, Ordering::SeqCst);
|
|
938
|
+
let pid = std::process::id();
|
|
939
|
+
let dir = std::env::temp_dir().join(format!(
|
|
940
|
+
"compass-cli-projtest-{}-{}-{}-{}",
|
|
941
|
+
tag, pid, nanos, n
|
|
942
|
+
));
|
|
943
|
+
fs::create_dir_all(&dir).expect("create unique tmp dir");
|
|
944
|
+
dir
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
fn cleanup(dir: &Path) {
|
|
948
|
+
let _ = fs::remove_dir_all(dir);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/// Build a minimal project root with `.compass/.state/config.json` inside
|
|
952
|
+
/// the supplied parent directory; returns the project root path
|
|
953
|
+
/// canonicalized so it matches what `canonicalize_path` produces at
|
|
954
|
+
/// runtime (important on macOS where `/var` is a symlink to
|
|
955
|
+
/// `/private/var`).
|
|
956
|
+
fn make_project(parent: &Path, name: &str) -> PathBuf {
|
|
957
|
+
let root = parent.join(name);
|
|
958
|
+
let state = root.join(".compass").join(".state");
|
|
959
|
+
fs::create_dir_all(&state).expect("create state dir");
|
|
960
|
+
let config = json!({
|
|
961
|
+
"version": "1.1.1",
|
|
962
|
+
"project": {"name": name, "po": "@test"},
|
|
963
|
+
});
|
|
964
|
+
fs::write(state.join("config.json"), config.to_string())
|
|
965
|
+
.expect("write config.json");
|
|
966
|
+
fs::canonicalize(&root).unwrap_or(root)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
fn parse(s: &str) -> Value {
|
|
970
|
+
serde_json::from_str(s).expect("valid json")
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/// RAII guard: locks the shared `HOME_GUARD` mutex, sets `$HOME` to a
|
|
974
|
+
/// unique tmp dir, and restores on drop. Also restores `cwd` if the caller
|
|
975
|
+
/// changed it by tracking the value present at guard construction.
|
|
976
|
+
struct HomeGuard {
|
|
977
|
+
home: PathBuf,
|
|
978
|
+
prev_home: Option<std::ffi::OsString>,
|
|
979
|
+
prev_cwd: Option<PathBuf>,
|
|
980
|
+
_lock: std::sync::MutexGuard<'static, ()>,
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
impl HomeGuard {
|
|
984
|
+
fn new(tag: &str) -> Self {
|
|
985
|
+
let lock = HOME_GUARD.lock().unwrap_or_else(|e| e.into_inner());
|
|
986
|
+
let home = unique_tmp_dir(tag);
|
|
987
|
+
let prev_home = std::env::var_os("HOME");
|
|
988
|
+
let prev_cwd = std::env::current_dir().ok();
|
|
989
|
+
std::env::set_var("HOME", &home);
|
|
990
|
+
fs::create_dir_all(home.join(".compass")).expect("mkdir ~/.compass");
|
|
991
|
+
HomeGuard {
|
|
992
|
+
home,
|
|
993
|
+
prev_home,
|
|
994
|
+
prev_cwd,
|
|
995
|
+
_lock: lock,
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
fn home(&self) -> &Path {
|
|
1000
|
+
&self.home
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
fn registry_file(&self) -> PathBuf {
|
|
1004
|
+
self.home.join(".compass").join("projects.json")
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
fn write_registry_raw(&self, body: &str) {
|
|
1008
|
+
fs::write(self.registry_file(), body).expect("write registry");
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
impl Drop for HomeGuard {
|
|
1013
|
+
fn drop(&mut self) {
|
|
1014
|
+
if let Some(p) = &self.prev_cwd {
|
|
1015
|
+
let _ = std::env::set_current_dir(p);
|
|
1016
|
+
}
|
|
1017
|
+
match &self.prev_home {
|
|
1018
|
+
Some(p) => std::env::set_var("HOME", p),
|
|
1019
|
+
None => std::env::remove_var("HOME"),
|
|
1020
|
+
}
|
|
1021
|
+
cleanup(&self.home);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ===== T-12 NAMED UNIT TESTS (15) =====
|
|
1026
|
+
|
|
1027
|
+
#[test]
|
|
1028
|
+
fn resolve_ok_single_alive() {
|
|
1029
|
+
let g = HomeGuard::new("t12_resolve_ok_single_alive");
|
|
1030
|
+
let parent = unique_tmp_dir("t12_ros_parent");
|
|
1031
|
+
let root = make_project(&parent, "proj_one");
|
|
1032
|
+
let root_str = root.to_string_lossy().to_string();
|
|
1033
|
+
|
|
1034
|
+
let reg = json!({
|
|
1035
|
+
"version": REGISTRY_VERSION,
|
|
1036
|
+
"last_active": root_str,
|
|
1037
|
+
"projects": [{
|
|
1038
|
+
"path": root_str,
|
|
1039
|
+
"name": "proj_one",
|
|
1040
|
+
"created_at": "2026-04-01T00:00:00Z",
|
|
1041
|
+
"last_used": "2026-04-01T00:00:00Z",
|
|
1042
|
+
}],
|
|
1043
|
+
});
|
|
1044
|
+
g.write_registry_raw(®.to_string());
|
|
1045
|
+
|
|
1046
|
+
let out = resolve().expect("resolve ok");
|
|
1047
|
+
let v = parse(&out);
|
|
1048
|
+
assert_eq!(v["status"], json!("ok"));
|
|
1049
|
+
assert_eq!(v["project_root"], json!(root_str));
|
|
1050
|
+
assert_eq!(v["name"], json!("proj_one"));
|
|
1051
|
+
assert_eq!(v["migrated_from_v11"], json!(false));
|
|
1052
|
+
assert!(v["config"].is_object());
|
|
1053
|
+
assert_eq!(v["config"]["project"]["name"], json!("proj_one"));
|
|
1054
|
+
|
|
1055
|
+
cleanup(&parent);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
#[test]
|
|
1059
|
+
fn resolve_ok_last_active_alive() {
|
|
1060
|
+
let g = HomeGuard::new("t12_resolve_ok_last_active");
|
|
1061
|
+
let parent = unique_tmp_dir("t12_rla_parent");
|
|
1062
|
+
let a = make_project(&parent, "alpha");
|
|
1063
|
+
let b = make_project(&parent, "beta");
|
|
1064
|
+
let c = make_project(&parent, "gamma");
|
|
1065
|
+
let a_str = a.to_string_lossy().to_string();
|
|
1066
|
+
let b_str = b.to_string_lossy().to_string();
|
|
1067
|
+
let c_str = c.to_string_lossy().to_string();
|
|
1068
|
+
|
|
1069
|
+
let reg = json!({
|
|
1070
|
+
"version": REGISTRY_VERSION,
|
|
1071
|
+
"last_active": b_str,
|
|
1072
|
+
"projects": [
|
|
1073
|
+
{"path": a_str, "name": "alpha", "created_at": "2026-04-01T00:00:00Z", "last_used": "2026-04-05T00:00:00Z"},
|
|
1074
|
+
{"path": b_str, "name": "beta", "created_at": "2026-04-02T00:00:00Z", "last_used": "2026-04-04T00:00:00Z"},
|
|
1075
|
+
{"path": c_str, "name": "gamma", "created_at": "2026-04-03T00:00:00Z", "last_used": "2026-04-03T00:00:00Z"},
|
|
1076
|
+
],
|
|
1077
|
+
});
|
|
1078
|
+
g.write_registry_raw(®.to_string());
|
|
1079
|
+
|
|
1080
|
+
let out = resolve().expect("resolve ok");
|
|
1081
|
+
let v = parse(&out);
|
|
1082
|
+
assert_eq!(v["status"], json!("ok"));
|
|
1083
|
+
assert_eq!(v["project_root"], json!(b_str));
|
|
1084
|
+
assert_eq!(v["name"], json!("beta"));
|
|
1085
|
+
assert_eq!(v["migrated_from_v11"], json!(false));
|
|
1086
|
+
|
|
1087
|
+
cleanup(&parent);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
#[test]
|
|
1091
|
+
fn resolve_ambiguous_last_active_dead() {
|
|
1092
|
+
let g = HomeGuard::new("t12_resolve_ambiguous");
|
|
1093
|
+
let parent = unique_tmp_dir("t12_amb_parent");
|
|
1094
|
+
let a = make_project(&parent, "alpha");
|
|
1095
|
+
let b = make_project(&parent, "beta");
|
|
1096
|
+
let a_str = a.to_string_lossy().to_string();
|
|
1097
|
+
let b_str = b.to_string_lossy().to_string();
|
|
1098
|
+
// Dead entry: real dir removed immediately.
|
|
1099
|
+
let dead_dir = parent.join("dead_one");
|
|
1100
|
+
let dead_str = dead_dir.to_string_lossy().to_string();
|
|
1101
|
+
|
|
1102
|
+
let reg = json!({
|
|
1103
|
+
"version": REGISTRY_VERSION,
|
|
1104
|
+
"last_active": dead_str,
|
|
1105
|
+
"projects": [
|
|
1106
|
+
{"path": dead_str, "name": "dead_one", "created_at": "2026-04-01T00:00:00Z", "last_used": "2026-04-10T00:00:00Z"},
|
|
1107
|
+
{"path": a_str, "name": "alpha", "created_at": "2026-04-02T00:00:00Z", "last_used": "2026-04-09T00:00:00Z"},
|
|
1108
|
+
{"path": b_str, "name": "beta", "created_at": "2026-04-03T00:00:00Z", "last_used": "2026-04-08T00:00:00Z"},
|
|
1109
|
+
],
|
|
1110
|
+
});
|
|
1111
|
+
g.write_registry_raw(®.to_string());
|
|
1112
|
+
|
|
1113
|
+
let out = resolve().expect("resolve ok");
|
|
1114
|
+
let v = parse(&out);
|
|
1115
|
+
assert_eq!(v["status"], json!("ambiguous"));
|
|
1116
|
+
let cands = v["candidates"].as_array().expect("candidates array");
|
|
1117
|
+
assert_eq!(cands.len(), 2, "should have 2 alive candidates");
|
|
1118
|
+
// Sorted desc by last_used: alpha (04-09) then beta (04-08).
|
|
1119
|
+
assert_eq!(cands[0]["path"], json!(a_str));
|
|
1120
|
+
assert_eq!(cands[1]["path"], json!(b_str));
|
|
1121
|
+
|
|
1122
|
+
// Registry on disk should be pruned.
|
|
1123
|
+
let reg_raw = fs::read_to_string(g.registry_file()).unwrap();
|
|
1124
|
+
let disk: Value = serde_json::from_str(®_raw).unwrap();
|
|
1125
|
+
let disk_projs = disk["projects"].as_array().unwrap();
|
|
1126
|
+
assert_eq!(disk_projs.len(), 2);
|
|
1127
|
+
assert!(disk_projs
|
|
1128
|
+
.iter()
|
|
1129
|
+
.all(|p| p["path"].as_str() != Some(&dead_str)));
|
|
1130
|
+
assert!(disk["last_active"].is_null(), "last_active cleared after prune");
|
|
1131
|
+
|
|
1132
|
+
cleanup(&parent);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
#[test]
|
|
1136
|
+
fn resolve_fallback_auto() {
|
|
1137
|
+
let g = HomeGuard::new("t12_resolve_fallback_auto");
|
|
1138
|
+
let parent = unique_tmp_dir("t12_rfa_parent");
|
|
1139
|
+
let alive = make_project(&parent, "alive_one");
|
|
1140
|
+
let alive_str = alive.to_string_lossy().to_string();
|
|
1141
|
+
let dead_str = parent.join("dead_one").to_string_lossy().to_string();
|
|
1142
|
+
|
|
1143
|
+
let reg = json!({
|
|
1144
|
+
"version": REGISTRY_VERSION,
|
|
1145
|
+
"last_active": dead_str,
|
|
1146
|
+
"projects": [
|
|
1147
|
+
{"path": dead_str, "name": "dead_one", "created_at": "2026-04-01T00:00:00Z", "last_used": "2026-04-10T00:00:00Z"},
|
|
1148
|
+
{"path": alive_str, "name": "alive_one", "created_at": "2026-04-02T00:00:00Z", "last_used": "2026-04-09T00:00:00Z"},
|
|
1149
|
+
],
|
|
1150
|
+
});
|
|
1151
|
+
g.write_registry_raw(®.to_string());
|
|
1152
|
+
|
|
1153
|
+
let out = resolve().expect("resolve ok");
|
|
1154
|
+
let v = parse(&out);
|
|
1155
|
+
assert_eq!(v["status"], json!("ok"));
|
|
1156
|
+
assert_eq!(v["project_root"], json!(alive_str));
|
|
1157
|
+
assert_eq!(v["name"], json!("alive_one"));
|
|
1158
|
+
|
|
1159
|
+
// Registry post: dead pruned, last_active reset to alive.
|
|
1160
|
+
let reg_raw = fs::read_to_string(g.registry_file()).unwrap();
|
|
1161
|
+
let disk: Value = serde_json::from_str(®_raw).unwrap();
|
|
1162
|
+
assert_eq!(disk["last_active"], json!(alive_str));
|
|
1163
|
+
let disk_projs = disk["projects"].as_array().unwrap();
|
|
1164
|
+
assert_eq!(disk_projs.len(), 1);
|
|
1165
|
+
assert_eq!(disk_projs[0]["path"], json!(alive_str));
|
|
1166
|
+
|
|
1167
|
+
cleanup(&parent);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
#[test]
|
|
1171
|
+
fn resolve_none_empty() {
|
|
1172
|
+
let _g = HomeGuard::new("t12_resolve_none_empty");
|
|
1173
|
+
|
|
1174
|
+
// cwd somewhere with no compass config.
|
|
1175
|
+
let cwd = unique_tmp_dir("t12_rne_cwd");
|
|
1176
|
+
std::env::set_current_dir(&cwd).unwrap();
|
|
1177
|
+
|
|
1178
|
+
let out = resolve().expect("resolve ok");
|
|
1179
|
+
let v = parse(&out);
|
|
1180
|
+
assert_eq!(v["status"], json!("none"));
|
|
1181
|
+
assert_eq!(v["reason"], json!("empty_registry"));
|
|
1182
|
+
|
|
1183
|
+
cleanup(&cwd);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
#[test]
|
|
1187
|
+
fn resolve_none_all_dead() {
|
|
1188
|
+
let g = HomeGuard::new("t12_resolve_none_all_dead");
|
|
1189
|
+
|
|
1190
|
+
// cwd far away from any compass project.
|
|
1191
|
+
let cwd = unique_tmp_dir("t12_rnad_cwd");
|
|
1192
|
+
std::env::set_current_dir(&cwd).unwrap();
|
|
1193
|
+
|
|
1194
|
+
let parent = unique_tmp_dir("t12_rnad_parent");
|
|
1195
|
+
let d1 = parent.join("dead_1").to_string_lossy().to_string();
|
|
1196
|
+
let d2 = parent.join("dead_2").to_string_lossy().to_string();
|
|
1197
|
+
|
|
1198
|
+
let reg = json!({
|
|
1199
|
+
"version": REGISTRY_VERSION,
|
|
1200
|
+
"last_active": d1,
|
|
1201
|
+
"projects": [
|
|
1202
|
+
{"path": d1, "name": "dead_1", "created_at": "2026-04-01T00:00:00Z", "last_used": "2026-04-10T00:00:00Z"},
|
|
1203
|
+
{"path": d2, "name": "dead_2", "created_at": "2026-04-02T00:00:00Z", "last_used": "2026-04-09T00:00:00Z"},
|
|
1204
|
+
],
|
|
1205
|
+
});
|
|
1206
|
+
g.write_registry_raw(®.to_string());
|
|
1207
|
+
|
|
1208
|
+
let out = resolve().expect("resolve ok");
|
|
1209
|
+
let v = parse(&out);
|
|
1210
|
+
assert_eq!(v["status"], json!("none"));
|
|
1211
|
+
assert_eq!(v["reason"], json!("all_paths_dead"));
|
|
1212
|
+
|
|
1213
|
+
// Registry on disk pruned to empty.
|
|
1214
|
+
let reg_raw = fs::read_to_string(g.registry_file()).unwrap();
|
|
1215
|
+
let disk: Value = serde_json::from_str(®_raw).unwrap();
|
|
1216
|
+
assert!(disk["projects"].as_array().unwrap().is_empty());
|
|
1217
|
+
assert!(disk["last_active"].is_null());
|
|
1218
|
+
|
|
1219
|
+
cleanup(&parent);
|
|
1220
|
+
cleanup(&cwd);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
#[test]
|
|
1224
|
+
fn resolve_migrates_v11() {
|
|
1225
|
+
let g = HomeGuard::new("t12_resolve_migrates_v11");
|
|
1226
|
+
|
|
1227
|
+
// No registry file at all.
|
|
1228
|
+
assert!(!g.registry_file().exists());
|
|
1229
|
+
|
|
1230
|
+
let parent = unique_tmp_dir("t12_mig_parent");
|
|
1231
|
+
let root = make_project(&parent, "legacy_proj");
|
|
1232
|
+
std::env::set_current_dir(&root).unwrap();
|
|
1233
|
+
|
|
1234
|
+
let out = resolve().expect("resolve ok");
|
|
1235
|
+
let v = parse(&out);
|
|
1236
|
+
assert_eq!(v["status"], json!("ok"));
|
|
1237
|
+
assert_eq!(v["migrated_from_v11"], json!(true));
|
|
1238
|
+
assert_eq!(v["name"], json!("legacy_proj"));
|
|
1239
|
+
|
|
1240
|
+
// Registry now exists with the cwd entry.
|
|
1241
|
+
assert!(g.registry_file().exists());
|
|
1242
|
+
let reg_raw = fs::read_to_string(g.registry_file()).unwrap();
|
|
1243
|
+
let disk: Value = serde_json::from_str(®_raw).unwrap();
|
|
1244
|
+
let projs = disk["projects"].as_array().unwrap();
|
|
1245
|
+
assert_eq!(projs.len(), 1);
|
|
1246
|
+
assert_eq!(projs[0]["name"], json!("legacy_proj"));
|
|
1247
|
+
assert!(disk["last_active"].is_string());
|
|
1248
|
+
|
|
1249
|
+
cleanup(&parent);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
#[test]
|
|
1253
|
+
fn use_updates_last_active() {
|
|
1254
|
+
let g = HomeGuard::new("t12_use_updates_last_active");
|
|
1255
|
+
let parent = unique_tmp_dir("t12_ula_parent");
|
|
1256
|
+
let a = make_project(&parent, "alpha");
|
|
1257
|
+
let b = make_project(&parent, "beta");
|
|
1258
|
+
let a_str = a.to_string_lossy().to_string();
|
|
1259
|
+
let b_str = b.to_string_lossy().to_string();
|
|
1260
|
+
|
|
1261
|
+
let reg = json!({
|
|
1262
|
+
"version": REGISTRY_VERSION,
|
|
1263
|
+
"last_active": a_str,
|
|
1264
|
+
"projects": [
|
|
1265
|
+
{"path": a_str, "name": "alpha", "created_at": "2026-04-01T00:00:00Z", "last_used": "2026-04-05T00:00:00Z"},
|
|
1266
|
+
{"path": b_str, "name": "beta", "created_at": "2026-04-02T00:00:00Z", "last_used": "2026-04-04T00:00:00Z"},
|
|
1267
|
+
],
|
|
1268
|
+
});
|
|
1269
|
+
g.write_registry_raw(®.to_string());
|
|
1270
|
+
|
|
1271
|
+
let out = use_project(&b_str).expect("use ok");
|
|
1272
|
+
let v = parse(&out);
|
|
1273
|
+
assert_eq!(v["ok"], json!(true));
|
|
1274
|
+
assert_eq!(v["active"], json!(b_str));
|
|
1275
|
+
|
|
1276
|
+
// Disk state: last_active = b, and b's last_used is fresh (non-empty, != old value).
|
|
1277
|
+
let reg_raw = fs::read_to_string(g.registry_file()).unwrap();
|
|
1278
|
+
let disk: Value = serde_json::from_str(®_raw).unwrap();
|
|
1279
|
+
assert_eq!(disk["last_active"], json!(b_str));
|
|
1280
|
+
let b_entry = disk["projects"]
|
|
1281
|
+
.as_array()
|
|
1282
|
+
.unwrap()
|
|
1283
|
+
.iter()
|
|
1284
|
+
.find(|p| p["path"].as_str() == Some(&b_str))
|
|
1285
|
+
.expect("entry b");
|
|
1286
|
+
let lu = b_entry["last_used"].as_str().unwrap_or("");
|
|
1287
|
+
assert!(!lu.is_empty());
|
|
1288
|
+
assert_ne!(lu, "2026-04-04T00:00:00Z", "last_used must be bumped");
|
|
1289
|
+
|
|
1290
|
+
cleanup(&parent);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
#[test]
|
|
1294
|
+
fn use_auto_adds() {
|
|
1295
|
+
let g = HomeGuard::new("t12_use_auto_adds");
|
|
1296
|
+
let parent = unique_tmp_dir("t12_uaa_parent");
|
|
1297
|
+
let root = make_project(&parent, "fresh_proj");
|
|
1298
|
+
let root_str = root.to_string_lossy().to_string();
|
|
1299
|
+
|
|
1300
|
+
// Registry starts empty / absent.
|
|
1301
|
+
assert!(!g.registry_file().exists());
|
|
1302
|
+
|
|
1303
|
+
let out = use_project(&root_str).expect("use ok");
|
|
1304
|
+
let v = parse(&out);
|
|
1305
|
+
assert_eq!(v["ok"], json!(true));
|
|
1306
|
+
assert_eq!(v["active"], json!(root_str));
|
|
1307
|
+
|
|
1308
|
+
let reg_raw = fs::read_to_string(g.registry_file()).unwrap();
|
|
1309
|
+
let disk: Value = serde_json::from_str(®_raw).unwrap();
|
|
1310
|
+
let projs = disk["projects"].as_array().unwrap();
|
|
1311
|
+
assert_eq!(projs.len(), 1);
|
|
1312
|
+
assert_eq!(projs[0]["path"], json!(root_str));
|
|
1313
|
+
assert_eq!(projs[0]["name"], json!("fresh_proj"));
|
|
1314
|
+
assert_eq!(disk["last_active"], json!(root_str));
|
|
1315
|
+
|
|
1316
|
+
cleanup(&parent);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
#[test]
|
|
1320
|
+
fn add_rejects_no_config() {
|
|
1321
|
+
let g = HomeGuard::new("t12_add_rejects_no_config");
|
|
1322
|
+
let bare = unique_tmp_dir("t12_arnc_bare");
|
|
1323
|
+
let bare_str = bare.to_string_lossy().to_string();
|
|
1324
|
+
|
|
1325
|
+
let err = add(&bare_str).expect_err("add must reject missing config");
|
|
1326
|
+
assert!(
|
|
1327
|
+
err.contains("NO_CONFIG_AT_PATH") || err.to_lowercase().contains("config"),
|
|
1328
|
+
"error should reference missing config, got: {}",
|
|
1329
|
+
err
|
|
1330
|
+
);
|
|
1331
|
+
|
|
1332
|
+
// Registry must remain unchanged (absent).
|
|
1333
|
+
assert!(
|
|
1334
|
+
!g.registry_file().exists(),
|
|
1335
|
+
"registry must not be created on rejected add"
|
|
1336
|
+
);
|
|
1337
|
+
|
|
1338
|
+
cleanup(&bare);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
#[test]
|
|
1342
|
+
fn list_sorted() {
|
|
1343
|
+
let g = HomeGuard::new("t12_list_sorted");
|
|
1344
|
+
|
|
1345
|
+
let reg = json!({
|
|
1346
|
+
"version": REGISTRY_VERSION,
|
|
1347
|
+
"last_active": "/tmp/compass-nonexistent-a",
|
|
1348
|
+
"projects": [
|
|
1349
|
+
{"path": "/tmp/compass-nonexistent-a", "name": "A", "created_at": "2026-04-01T00:00:00Z", "last_used": "2026-04-10T00:00:00Z"},
|
|
1350
|
+
{"path": "/tmp/compass-nonexistent-b", "name": "B", "created_at": "2026-04-02T00:00:00Z", "last_used": "2026-04-13T00:00:00Z"},
|
|
1351
|
+
{"path": "/tmp/compass-nonexistent-c", "name": "C", "created_at": "2026-04-03T00:00:00Z", "last_used": "2026-04-11T00:00:00Z"},
|
|
1352
|
+
],
|
|
1353
|
+
});
|
|
1354
|
+
g.write_registry_raw(®.to_string());
|
|
1355
|
+
|
|
1356
|
+
let out = list().expect("list ok");
|
|
1357
|
+
let arr = parse(&out);
|
|
1358
|
+
let rows = arr.as_array().expect("array");
|
|
1359
|
+
assert_eq!(rows.len(), 3);
|
|
1360
|
+
assert_eq!(rows[0]["path"], json!("/tmp/compass-nonexistent-b"));
|
|
1361
|
+
assert_eq!(rows[1]["path"], json!("/tmp/compass-nonexistent-c"));
|
|
1362
|
+
assert_eq!(rows[2]["path"], json!("/tmp/compass-nonexistent-a"));
|
|
1363
|
+
assert_eq!(rows[0]["is_active"], json!(false));
|
|
1364
|
+
assert_eq!(rows[1]["is_active"], json!(false));
|
|
1365
|
+
assert_eq!(rows[2]["is_active"], json!(true));
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
#[test]
|
|
1369
|
+
fn remove_clears_last_active() {
|
|
1370
|
+
let g = HomeGuard::new("t12_remove_clears_last_active");
|
|
1371
|
+
let parent = unique_tmp_dir("t12_rcla_parent");
|
|
1372
|
+
let a = make_project(&parent, "alpha");
|
|
1373
|
+
let b = make_project(&parent, "beta");
|
|
1374
|
+
let a_str = a.to_string_lossy().to_string();
|
|
1375
|
+
let b_str = b.to_string_lossy().to_string();
|
|
1376
|
+
|
|
1377
|
+
let reg = json!({
|
|
1378
|
+
"version": REGISTRY_VERSION,
|
|
1379
|
+
"last_active": a_str,
|
|
1380
|
+
"projects": [
|
|
1381
|
+
{"path": a_str, "name": "alpha", "created_at": "2026-04-01T00:00:00Z", "last_used": "2026-04-05T00:00:00Z"},
|
|
1382
|
+
{"path": b_str, "name": "beta", "created_at": "2026-04-02T00:00:00Z", "last_used": "2026-04-04T00:00:00Z"},
|
|
1383
|
+
],
|
|
1384
|
+
});
|
|
1385
|
+
g.write_registry_raw(®.to_string());
|
|
1386
|
+
|
|
1387
|
+
let out = remove(&a_str).expect("remove ok");
|
|
1388
|
+
let v = parse(&out);
|
|
1389
|
+
assert_eq!(v["ok"], json!(true));
|
|
1390
|
+
assert_eq!(v["removed"], json!(a_str));
|
|
1391
|
+
|
|
1392
|
+
let reg_raw = fs::read_to_string(g.registry_file()).unwrap();
|
|
1393
|
+
let disk: Value = serde_json::from_str(®_raw).unwrap();
|
|
1394
|
+
assert!(
|
|
1395
|
+
disk["last_active"].is_null(),
|
|
1396
|
+
"last_active must be cleared after removing the active entry; got {:?}",
|
|
1397
|
+
disk["last_active"]
|
|
1398
|
+
);
|
|
1399
|
+
let projs = disk["projects"].as_array().unwrap();
|
|
1400
|
+
assert_eq!(projs.len(), 1);
|
|
1401
|
+
assert_eq!(projs[0]["path"], json!(b_str));
|
|
1402
|
+
|
|
1403
|
+
cleanup(&parent);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
#[test]
|
|
1407
|
+
fn registry_file_lock() {
|
|
1408
|
+
let g = HomeGuard::new("t12_registry_file_lock");
|
|
1409
|
+
let parent = unique_tmp_dir("t12_rfl_parent");
|
|
1410
|
+
let a = make_project(&parent, "alpha");
|
|
1411
|
+
let b = make_project(&parent, "beta");
|
|
1412
|
+
let a_str = a.to_string_lossy().to_string();
|
|
1413
|
+
let b_str = b.to_string_lossy().to_string();
|
|
1414
|
+
|
|
1415
|
+
// Seed registry with both entries to avoid concurrent read-auto-add
|
|
1416
|
+
// races; both threads just bump last_active.
|
|
1417
|
+
let reg = json!({
|
|
1418
|
+
"version": REGISTRY_VERSION,
|
|
1419
|
+
"last_active": Value::Null,
|
|
1420
|
+
"projects": [
|
|
1421
|
+
{"path": a_str, "name": "alpha", "created_at": "2026-04-01T00:00:00Z", "last_used": "2026-04-01T00:00:00Z"},
|
|
1422
|
+
{"path": b_str, "name": "beta", "created_at": "2026-04-02T00:00:00Z", "last_used": "2026-04-02T00:00:00Z"},
|
|
1423
|
+
],
|
|
1424
|
+
});
|
|
1425
|
+
g.write_registry_raw(®.to_string());
|
|
1426
|
+
|
|
1427
|
+
let a_c = a_str.clone();
|
|
1428
|
+
let b_c = b_str.clone();
|
|
1429
|
+
let t1 = std::thread::spawn(move || use_project(&a_c));
|
|
1430
|
+
let t2 = std::thread::spawn(move || use_project(&b_c));
|
|
1431
|
+
let r1 = t1.join().expect("thread 1 joined");
|
|
1432
|
+
let r2 = t2.join().expect("thread 2 joined");
|
|
1433
|
+
assert!(r1.is_ok(), "thread 1 failed: {:?}", r1);
|
|
1434
|
+
assert!(r2.is_ok(), "thread 2 failed: {:?}", r2);
|
|
1435
|
+
|
|
1436
|
+
// Registry must still be valid JSON and last_active must match one
|
|
1437
|
+
// of the two paths.
|
|
1438
|
+
let reg_raw = fs::read_to_string(g.registry_file()).expect("read registry");
|
|
1439
|
+
let disk: Value = serde_json::from_str(®_raw).expect("valid JSON after concurrent use");
|
|
1440
|
+
let la = disk["last_active"].as_str().unwrap_or("");
|
|
1441
|
+
assert!(
|
|
1442
|
+
la == a_str || la == b_str,
|
|
1443
|
+
"last_active must be one of the used paths; got {}",
|
|
1444
|
+
la
|
|
1445
|
+
);
|
|
1446
|
+
let projs = disk["projects"].as_array().unwrap();
|
|
1447
|
+
assert_eq!(projs.len(), 2, "no duplicate entries created");
|
|
1448
|
+
|
|
1449
|
+
cleanup(&parent);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
#[test]
|
|
1453
|
+
fn registry_corrupt_backup() {
|
|
1454
|
+
let g = HomeGuard::new("t12_registry_corrupt_backup");
|
|
1455
|
+
let cwd = unique_tmp_dir("t12_rcb_cwd");
|
|
1456
|
+
std::env::set_current_dir(&cwd).unwrap();
|
|
1457
|
+
|
|
1458
|
+
let reg_path = g.registry_file();
|
|
1459
|
+
fs::write(®_path, "{broken-json").unwrap();
|
|
1460
|
+
|
|
1461
|
+
let out = resolve().expect("resolve ok despite corrupt registry");
|
|
1462
|
+
let v = parse(&out);
|
|
1463
|
+
assert_eq!(v["status"], json!("none"));
|
|
1464
|
+
assert_eq!(v["reason"], json!("empty_registry"));
|
|
1465
|
+
|
|
1466
|
+
// Backup file exists with bad payload.
|
|
1467
|
+
let bak = reg_path.with_extension("json.bak");
|
|
1468
|
+
assert!(bak.exists(), "backup .bak must exist");
|
|
1469
|
+
let bak_raw = fs::read_to_string(&bak).unwrap();
|
|
1470
|
+
assert!(bak_raw.contains("broken-json"), "backup should contain original bad payload");
|
|
1471
|
+
|
|
1472
|
+
// Main registry reset to empty skeleton.
|
|
1473
|
+
let reg_raw = fs::read_to_string(®_path).unwrap();
|
|
1474
|
+
let disk: Value = serde_json::from_str(®_raw).expect("main registry parseable again");
|
|
1475
|
+
assert!(disk["projects"].as_array().unwrap().is_empty());
|
|
1476
|
+
assert!(disk["last_active"].is_null());
|
|
1477
|
+
assert_eq!(disk["version"], json!(REGISTRY_VERSION));
|
|
1478
|
+
|
|
1479
|
+
cleanup(&cwd);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
#[test]
|
|
1483
|
+
fn global_config_init() {
|
|
1484
|
+
let g = HomeGuard::new("t12_global_config_init");
|
|
1485
|
+
let gc_path = g.home().join(".compass").join("global-config.json");
|
|
1486
|
+
assert!(!gc_path.exists());
|
|
1487
|
+
|
|
1488
|
+
let out = global_config_set("lang", "vi").expect("set ok");
|
|
1489
|
+
let v = parse(&out);
|
|
1490
|
+
assert_eq!(v["ok"], json!(true));
|
|
1491
|
+
assert_eq!(v["key"], json!("lang"));
|
|
1492
|
+
|
|
1493
|
+
assert!(gc_path.exists(), "global-config.json created");
|
|
1494
|
+
let raw = fs::read_to_string(&gc_path).unwrap();
|
|
1495
|
+
let disk: Value = serde_json::from_str(&raw).unwrap();
|
|
1496
|
+
assert_eq!(disk["version"], json!(GLOBAL_CONFIG_VERSION));
|
|
1497
|
+
assert_eq!(disk["lang"], json!("vi"));
|
|
1498
|
+
assert!(disk["created_at"].is_string());
|
|
1499
|
+
assert!(disk["updated_at"].is_string());
|
|
1500
|
+
|
|
1501
|
+
// Round-trip: get --key lang returns "vi".
|
|
1502
|
+
let got = global_config_get(Some("lang")).expect("get ok");
|
|
1503
|
+
let got_v: Value = serde_json::from_str(&got).unwrap();
|
|
1504
|
+
assert_eq!(got_v, json!("vi"));
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// ===== Legacy smoke tests (renamed per T-12 rules) =====
|
|
1508
|
+
|
|
1509
|
+
#[test]
|
|
1510
|
+
fn resolve_empty_registry_returns_none_smoke() {
|
|
1511
|
+
let _g = HOME_GUARD.lock().unwrap();
|
|
1512
|
+
let home = unique_tmp_dir("resolve_empty");
|
|
1513
|
+
let prev = std::env::var_os("HOME");
|
|
1514
|
+
std::env::set_var("HOME", &home);
|
|
1515
|
+
|
|
1516
|
+
// cwd far away from any compass project — pick the tmp dir itself,
|
|
1517
|
+
// which has no `.compass/.state/config.json`.
|
|
1518
|
+
let cwd_guard = unique_tmp_dir("resolve_empty_cwd");
|
|
1519
|
+
let prev_cwd = std::env::current_dir().ok();
|
|
1520
|
+
std::env::set_current_dir(&cwd_guard).unwrap();
|
|
1521
|
+
|
|
1522
|
+
let out = resolve().expect("resolve ok");
|
|
1523
|
+
let v = parse(&out);
|
|
1524
|
+
assert_eq!(v["status"], json!("none"));
|
|
1525
|
+
assert_eq!(v["reason"], json!("empty_registry"));
|
|
1526
|
+
|
|
1527
|
+
// Restore.
|
|
1528
|
+
if let Some(p) = prev_cwd {
|
|
1529
|
+
let _ = std::env::set_current_dir(p);
|
|
1530
|
+
}
|
|
1531
|
+
match prev {
|
|
1532
|
+
Some(p) => std::env::set_var("HOME", p),
|
|
1533
|
+
None => std::env::remove_var("HOME"),
|
|
1534
|
+
}
|
|
1535
|
+
cleanup(&home);
|
|
1536
|
+
cleanup(&cwd_guard);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
#[test]
|
|
1540
|
+
fn resolve_one_alive_returns_ok_smoke() {
|
|
1541
|
+
let _g = HOME_GUARD.lock().unwrap();
|
|
1542
|
+
let home = unique_tmp_dir("resolve_one_alive");
|
|
1543
|
+
let prev = std::env::var_os("HOME");
|
|
1544
|
+
std::env::set_var("HOME", &home);
|
|
1545
|
+
|
|
1546
|
+
let projects_parent = unique_tmp_dir("resolve_one_alive_projs");
|
|
1547
|
+
let root = make_project(&projects_parent, "proj_one");
|
|
1548
|
+
let root_str = root.to_string_lossy().to_string();
|
|
1549
|
+
|
|
1550
|
+
// Seed registry.
|
|
1551
|
+
fs::create_dir_all(home.join(".compass")).unwrap();
|
|
1552
|
+
let reg = json!({
|
|
1553
|
+
"version": "1.0",
|
|
1554
|
+
"last_active": root_str,
|
|
1555
|
+
"projects": [{
|
|
1556
|
+
"path": root_str,
|
|
1557
|
+
"name": "proj_one",
|
|
1558
|
+
"created_at": "2026-04-01T00:00:00Z",
|
|
1559
|
+
"last_used": "2026-04-01T00:00:00Z",
|
|
1560
|
+
}],
|
|
1561
|
+
});
|
|
1562
|
+
fs::write(home.join(".compass").join("projects.json"), reg.to_string()).unwrap();
|
|
1563
|
+
|
|
1564
|
+
let out = resolve().expect("resolve ok");
|
|
1565
|
+
let v = parse(&out);
|
|
1566
|
+
assert_eq!(v["status"], json!("ok"));
|
|
1567
|
+
assert_eq!(v["project_root"], json!(root_str));
|
|
1568
|
+
assert_eq!(v["name"], json!("proj_one"));
|
|
1569
|
+
assert_eq!(v["migrated_from_v11"], json!(false));
|
|
1570
|
+
assert!(v["config"].is_object());
|
|
1571
|
+
|
|
1572
|
+
match prev {
|
|
1573
|
+
Some(p) => std::env::set_var("HOME", p),
|
|
1574
|
+
None => std::env::remove_var("HOME"),
|
|
1575
|
+
}
|
|
1576
|
+
cleanup(&home);
|
|
1577
|
+
cleanup(&projects_parent);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
#[test]
|
|
1581
|
+
fn use_sets_last_active_smoke() {
|
|
1582
|
+
let _g = HOME_GUARD.lock().unwrap();
|
|
1583
|
+
let home = unique_tmp_dir("use_sets_active");
|
|
1584
|
+
let prev = std::env::var_os("HOME");
|
|
1585
|
+
std::env::set_var("HOME", &home);
|
|
1586
|
+
|
|
1587
|
+
let projects_parent = unique_tmp_dir("use_sets_active_projs");
|
|
1588
|
+
let root = make_project(&projects_parent, "proj_use");
|
|
1589
|
+
let root_str = root.to_string_lossy().to_string();
|
|
1590
|
+
|
|
1591
|
+
let out = use_project(&root_str).expect("use ok");
|
|
1592
|
+
let v = parse(&out);
|
|
1593
|
+
assert_eq!(v["ok"], json!(true));
|
|
1594
|
+
assert_eq!(v["active"], json!(root_str));
|
|
1595
|
+
|
|
1596
|
+
// Verify registry on disk.
|
|
1597
|
+
let reg_raw = fs::read_to_string(home.join(".compass").join("projects.json"))
|
|
1598
|
+
.expect("registry exists");
|
|
1599
|
+
let reg: Value = serde_json::from_str(®_raw).unwrap();
|
|
1600
|
+
assert_eq!(reg["last_active"], json!(root_str));
|
|
1601
|
+
assert_eq!(reg["projects"].as_array().unwrap().len(), 1);
|
|
1602
|
+
|
|
1603
|
+
match prev {
|
|
1604
|
+
Some(p) => std::env::set_var("HOME", p),
|
|
1605
|
+
None => std::env::remove_var("HOME"),
|
|
1606
|
+
}
|
|
1607
|
+
cleanup(&home);
|
|
1608
|
+
cleanup(&projects_parent);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
#[test]
|
|
1612
|
+
fn add_requires_config_smoke() {
|
|
1613
|
+
let _g = HOME_GUARD.lock().unwrap();
|
|
1614
|
+
let home = unique_tmp_dir("add_requires_config");
|
|
1615
|
+
let prev = std::env::var_os("HOME");
|
|
1616
|
+
std::env::set_var("HOME", &home);
|
|
1617
|
+
|
|
1618
|
+
// Directory exists but has no .compass/.state/config.json.
|
|
1619
|
+
let bare = unique_tmp_dir("add_bare");
|
|
1620
|
+
let bare_str = bare.to_string_lossy().to_string();
|
|
1621
|
+
|
|
1622
|
+
let err = add(&bare_str).expect_err("add must reject missing config");
|
|
1623
|
+
assert!(
|
|
1624
|
+
err.contains("NO_CONFIG_AT_PATH"),
|
|
1625
|
+
"error should mention NO_CONFIG_AT_PATH, got: {}",
|
|
1626
|
+
err
|
|
1627
|
+
);
|
|
1628
|
+
|
|
1629
|
+
match prev {
|
|
1630
|
+
Some(p) => std::env::set_var("HOME", p),
|
|
1631
|
+
None => std::env::remove_var("HOME"),
|
|
1632
|
+
}
|
|
1633
|
+
cleanup(&home);
|
|
1634
|
+
cleanup(&bare);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
#[test]
|
|
1638
|
+
fn list_sorted_by_last_used_smoke() {
|
|
1639
|
+
let _g = HOME_GUARD.lock().unwrap();
|
|
1640
|
+
let home = unique_tmp_dir("list_sorted");
|
|
1641
|
+
let prev = std::env::var_os("HOME");
|
|
1642
|
+
std::env::set_var("HOME", &home);
|
|
1643
|
+
|
|
1644
|
+
fs::create_dir_all(home.join(".compass")).unwrap();
|
|
1645
|
+
let reg = json!({
|
|
1646
|
+
"version": "1.0",
|
|
1647
|
+
"last_active": "/tmp/a",
|
|
1648
|
+
"projects": [
|
|
1649
|
+
{"path": "/tmp/a", "name": "A", "created_at": "2026-04-01T00:00:00Z", "last_used": "2026-04-10T00:00:00Z"},
|
|
1650
|
+
{"path": "/tmp/b", "name": "B", "created_at": "2026-04-02T00:00:00Z", "last_used": "2026-04-13T00:00:00Z"},
|
|
1651
|
+
{"path": "/tmp/c", "name": "C", "created_at": "2026-04-03T00:00:00Z", "last_used": "2026-04-11T00:00:00Z"},
|
|
1652
|
+
],
|
|
1653
|
+
});
|
|
1654
|
+
fs::write(home.join(".compass").join("projects.json"), reg.to_string()).unwrap();
|
|
1655
|
+
|
|
1656
|
+
let out = list().expect("list ok");
|
|
1657
|
+
let arr = parse(&out);
|
|
1658
|
+
let rows = arr.as_array().expect("array");
|
|
1659
|
+
assert_eq!(rows.len(), 3);
|
|
1660
|
+
assert_eq!(rows[0]["path"], json!("/tmp/b"));
|
|
1661
|
+
assert_eq!(rows[1]["path"], json!("/tmp/c"));
|
|
1662
|
+
assert_eq!(rows[2]["path"], json!("/tmp/a"));
|
|
1663
|
+
assert_eq!(rows[0]["is_active"], json!(false));
|
|
1664
|
+
assert_eq!(rows[2]["is_active"], json!(true));
|
|
1665
|
+
|
|
1666
|
+
match prev {
|
|
1667
|
+
Some(p) => std::env::set_var("HOME", p),
|
|
1668
|
+
None => std::env::remove_var("HOME"),
|
|
1669
|
+
}
|
|
1670
|
+
cleanup(&home);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
#[test]
|
|
1674
|
+
fn corrupt_registry_backed_up_and_reset_smoke() {
|
|
1675
|
+
let _g = HOME_GUARD.lock().unwrap();
|
|
1676
|
+
let home = unique_tmp_dir("corrupt_registry");
|
|
1677
|
+
let prev = std::env::var_os("HOME");
|
|
1678
|
+
std::env::set_var("HOME", &home);
|
|
1679
|
+
|
|
1680
|
+
fs::create_dir_all(home.join(".compass")).unwrap();
|
|
1681
|
+
let reg_path = home.join(".compass").join("projects.json");
|
|
1682
|
+
fs::write(®_path, "{not-valid json").unwrap();
|
|
1683
|
+
|
|
1684
|
+
// load_registry_or_reset is pure-read-with-side-effects.
|
|
1685
|
+
let v = load_registry_or_reset();
|
|
1686
|
+
assert_eq!(v["version"], json!(REGISTRY_VERSION));
|
|
1687
|
+
assert!(v["projects"].as_array().unwrap().is_empty());
|
|
1688
|
+
|
|
1689
|
+
let bak = home.join(".compass").join("projects.json.bak");
|
|
1690
|
+
assert!(bak.exists(), "backup should exist at {}", bak.display());
|
|
1691
|
+
let bak_raw = fs::read_to_string(&bak).unwrap();
|
|
1692
|
+
assert!(bak_raw.contains("not-valid"));
|
|
1693
|
+
|
|
1694
|
+
match prev {
|
|
1695
|
+
Some(p) => std::env::set_var("HOME", p),
|
|
1696
|
+
None => std::env::remove_var("HOME"),
|
|
1697
|
+
}
|
|
1698
|
+
cleanup(&home);
|
|
1699
|
+
}
|
|
1700
|
+
}
|