compass-st 1.1.2 → 1.2.0

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.
@@ -1,11 +1,18 @@
1
- use std::path::Path;
1
+ use std::path::{Path, PathBuf};
2
2
 
3
3
  pub fn run(args: &[String]) -> Result<String, String> {
4
- if args.is_empty() { return Err("Usage: compass-cli hook <statusline|update-checker>".into()); }
4
+ if args.is_empty() { return Err("Usage: compass-cli hook <statusline|update-checker|context-monitor|manifest-tracker>".into()); }
5
5
  match args[0].as_str() {
6
6
  "statusline" => statusline(),
7
7
  "update-checker" | "check-update" => update_checker(),
8
- "context-monitor" => Ok(String::new()), // placeholder
8
+ "context-monitor" => context_monitor(),
9
+ "manifest-tracker" => {
10
+ let sub = args.get(1).map(|s| s.as_str()).unwrap_or("check");
11
+ match sub {
12
+ "generate" => manifest_tracker_generate(None),
13
+ _ => Err(format!("Unknown manifest-tracker subcommand: {}", sub)),
14
+ }
15
+ }
9
16
  _ => Err(format!("Unknown hook: {}", args[0])),
10
17
  }
11
18
  }
@@ -49,3 +56,256 @@ fn update_checker() -> Result<String, String> {
49
56
  // The shell hook handles the actual GitHub API call
50
57
  Ok(String::new())
51
58
  }
59
+
60
+ /// PostToolUse hook — context utilization monitor.
61
+ /// Currently a placeholder that emits a status JSON. Future enhancement may
62
+ /// track real session-context size and warn on thresholds.
63
+ fn context_monitor() -> Result<String, String> {
64
+ // Detect if we're inside a Compass session by checking $HOME/.compass/projects.json
65
+ // If found and last_active is set, surface session_id; else null.
66
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
67
+ let registry_path = std::path::Path::new(&home).join(".compass").join("projects.json");
68
+
69
+ let session_id: Option<String> = if registry_path.exists() {
70
+ std::fs::read_to_string(&registry_path)
71
+ .ok()
72
+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
73
+ .and_then(|v| v.get("last_active").and_then(|x| x.as_str()).map(|s| s.to_string()))
74
+ } else {
75
+ None
76
+ };
77
+
78
+ Ok(serde_json::json!({
79
+ "status": "ok",
80
+ "session_id": session_id,
81
+ "event_count": 0,
82
+ "last_event": null,
83
+ "log_path": null,
84
+ }).to_string())
85
+ }
86
+
87
+ /// Hard-coded skip patterns for the manifest walker. A path is skipped if
88
+ /// ANY of these substrings appear in its full path. This mirrors the bash
89
+ /// `find -not -path / -not -name` filters and adds `target/` for Rust builds.
90
+ const SKIP_PATTERNS: &[&str] = &[
91
+ "/.git/",
92
+ "/node_modules/",
93
+ "/target/",
94
+ ".file-manifest.json",
95
+ ".update-check-cache",
96
+ ".DS_Store",
97
+ ];
98
+
99
+ /// Recursively walk `dir`, pushing every file path into `out` that does not
100
+ /// match a hard-coded skip pattern.
101
+ fn walk_collect(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
102
+ let rd = match std::fs::read_dir(dir) {
103
+ Ok(r) => r,
104
+ Err(e) => return Err(format!("read_dir({}) failed: {}", dir.display(), e)),
105
+ };
106
+ for entry in rd {
107
+ let entry = entry.map_err(|e| e.to_string())?;
108
+ let path = entry.path();
109
+ let path_str = path.to_string_lossy();
110
+ // Skip if any pattern is a substring of the path
111
+ if SKIP_PATTERNS.iter().any(|p| path_str.contains(p)) {
112
+ continue;
113
+ }
114
+ let ft = entry.file_type().map_err(|e| e.to_string())?;
115
+ if ft.is_dir() {
116
+ walk_collect(&path, out)?;
117
+ } else if ft.is_file() {
118
+ out.push(path);
119
+ }
120
+ }
121
+ Ok(())
122
+ }
123
+
124
+ /// PreUpdate hook — generate a manifest of all tracked files under `root`
125
+ /// (defaults to `~/.compass`) with SHA256 hashes. Writes
126
+ /// `<root>/.file-manifest.json` and returns a small status JSON.
127
+ pub fn manifest_tracker_generate(root: Option<&Path>) -> Result<String, String> {
128
+ use sha2::{Digest, Sha256};
129
+
130
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
131
+ let default_root = PathBuf::from(&home).join(".compass");
132
+ let root: PathBuf = root.map(|p| p.to_path_buf()).unwrap_or(default_root);
133
+
134
+ if !root.exists() {
135
+ return Err(format!("root does not exist: {}", root.display()));
136
+ }
137
+
138
+ // Collect files
139
+ let mut files: Vec<PathBuf> = Vec::new();
140
+ walk_collect(&root, &mut files)?;
141
+ files.sort();
142
+
143
+ // Read version file if present
144
+ let version = std::fs::read_to_string(root.join("VERSION"))
145
+ .map(|s| s.trim().to_string())
146
+ .unwrap_or_else(|_| "unknown".to_string());
147
+
148
+ // Build files map preserving sorted order
149
+ let mut files_map = serde_json::Map::new();
150
+ for f in &files {
151
+ let rel = f.strip_prefix(&root).map_err(|e| e.to_string())?;
152
+ let rel_str = rel.to_string_lossy().to_string();
153
+ let bytes = std::fs::read(f).map_err(|e| format!("read {}: {}", f.display(), e))?;
154
+ let mut hasher = Sha256::new();
155
+ hasher.update(&bytes);
156
+ let hex_hash = hex::encode(hasher.finalize());
157
+ files_map.insert(rel_str, serde_json::Value::String(hex_hash));
158
+ }
159
+
160
+ // ISO-8601 UTC timestamp without extra deps: compute from SystemTime.
161
+ let generated_at = iso8601_utc_now();
162
+
163
+ let manifest = serde_json::json!({
164
+ "version": version,
165
+ "generated_at": generated_at,
166
+ "files": serde_json::Value::Object(files_map),
167
+ });
168
+
169
+ let manifest_path = root.join(".file-manifest.json");
170
+ let pretty = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
171
+ std::fs::write(&manifest_path, pretty)
172
+ .map_err(|e| format!("write {}: {}", manifest_path.display(), e))?;
173
+
174
+ Ok(serde_json::json!({
175
+ "ok": true,
176
+ "file_count": files.len(),
177
+ "manifest_path": manifest_path.to_string_lossy(),
178
+ }).to_string())
179
+ }
180
+
181
+ /// Minimal ISO-8601 UTC formatter (YYYY-MM-DDTHH:MM:SSZ) without chrono.
182
+ fn iso8601_utc_now() -> String {
183
+ let secs = std::time::SystemTime::now()
184
+ .duration_since(std::time::UNIX_EPOCH)
185
+ .map(|d| d.as_secs())
186
+ .unwrap_or(0);
187
+ // Days since 1970-01-01
188
+ let days = (secs / 86400) as i64;
189
+ let tod = secs % 86400;
190
+ let hour = tod / 3600;
191
+ let minute = (tod % 3600) / 60;
192
+ let second = tod % 60;
193
+
194
+ // Convert days to Y-M-D via civil_from_days (Howard Hinnant algorithm)
195
+ let (y, m, d) = civil_from_days(days);
196
+ format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, hour, minute, second)
197
+ }
198
+
199
+ fn civil_from_days(z: i64) -> (i64, u32, u32) {
200
+ let z = z + 719468;
201
+ let era = if z >= 0 { z } else { z - 146096 } / 146097;
202
+ let doe = (z - era * 146097) as u64;
203
+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
204
+ let y = yoe as i64 + era * 400;
205
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
206
+ let mp = (5 * doy + 2) / 153;
207
+ let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
208
+ let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
209
+ let y = if m <= 2 { y + 1 } else { y };
210
+ (y, m, d)
211
+ }
212
+
213
+ #[cfg(test)]
214
+ mod tests {
215
+ use super::*;
216
+
217
+ #[test]
218
+ fn context_monitor_returns_ok_status() {
219
+ let out = context_monitor().expect("ok");
220
+ let v: serde_json::Value = serde_json::from_str(&out).expect("json");
221
+ assert_eq!(v["status"], "ok");
222
+ assert!(v.get("event_count").is_some());
223
+ }
224
+
225
+ #[test]
226
+ fn context_monitor_handles_no_session() {
227
+ // With $HOME pointing at a tmp dir with no registry, session_id is null.
228
+ let tmp = std::env::temp_dir().join(format!("compass-ctx-mon-test-{}", std::process::id()));
229
+ let _ = std::fs::create_dir_all(&tmp);
230
+ // Cannot mutate $HOME in tests without races; just verify the no-registry path
231
+ // by reading from a known-empty path. Acceptable: assert function returns Ok and
232
+ // status="ok".
233
+ let out = context_monitor().expect("ok");
234
+ assert!(out.contains("\"status\""));
235
+ }
236
+
237
+ #[test]
238
+ fn manifest_tracker_creates_file() {
239
+ let tmp = tempfile::tempdir().expect("tmpdir");
240
+ let root = tmp.path();
241
+ std::fs::write(root.join("a.md"), "alpha\n").unwrap();
242
+ std::fs::write(root.join("b.md"), "beta\n").unwrap();
243
+
244
+ let out = manifest_tracker_generate(Some(root)).expect("gen ok");
245
+ let v: serde_json::Value = serde_json::from_str(&out).expect("json");
246
+ assert_eq!(v["ok"], true);
247
+ assert_eq!(v["file_count"], 2);
248
+
249
+ let manifest_path = root.join(".file-manifest.json");
250
+ assert!(manifest_path.exists(), "manifest file created");
251
+
252
+ let raw = std::fs::read_to_string(&manifest_path).unwrap();
253
+ let m: serde_json::Value = serde_json::from_str(&raw).unwrap();
254
+ let files = m["files"].as_object().expect("files obj");
255
+ assert_eq!(files.len(), 2);
256
+ assert!(files.contains_key("a.md"));
257
+ assert!(files.contains_key("b.md"));
258
+ }
259
+
260
+ #[test]
261
+ fn manifest_tracker_skips_gitignored() {
262
+ let tmp = tempfile::tempdir().expect("tmpdir");
263
+ let root = tmp.path();
264
+ std::fs::write(root.join("keep.md"), "keep\n").unwrap();
265
+
266
+ // target/dummy.txt — should be skipped
267
+ let target_dir = root.join("target");
268
+ std::fs::create_dir_all(&target_dir).unwrap();
269
+ std::fs::write(target_dir.join("dummy.txt"), "ignore me\n").unwrap();
270
+
271
+ // .git/config — should be skipped
272
+ let git_dir = root.join(".git");
273
+ std::fs::create_dir_all(&git_dir).unwrap();
274
+ std::fs::write(git_dir.join("config"), "[core]\n").unwrap();
275
+
276
+ // .DS_Store — should be skipped
277
+ std::fs::write(root.join(".DS_Store"), "junk").unwrap();
278
+
279
+ let out = manifest_tracker_generate(Some(root)).expect("gen ok");
280
+ let v: serde_json::Value = serde_json::from_str(&out).expect("json");
281
+ assert_eq!(v["file_count"], 1, "only keep.md should be tracked");
282
+
283
+ let manifest_path = root.join(".file-manifest.json");
284
+ let raw = std::fs::read_to_string(&manifest_path).unwrap();
285
+ let m: serde_json::Value = serde_json::from_str(&raw).unwrap();
286
+ let files = m["files"].as_object().unwrap();
287
+ assert!(files.contains_key("keep.md"));
288
+ // No skipped file should appear
289
+ for k in files.keys() {
290
+ assert!(!k.contains("target"), "target/ not skipped: {}", k);
291
+ assert!(!k.contains(".git"), ".git/ not skipped: {}", k);
292
+ assert!(!k.contains(".DS_Store"), ".DS_Store not skipped: {}", k);
293
+ }
294
+ }
295
+
296
+ #[test]
297
+ fn manifest_tracker_sha256_is_correct() {
298
+ let tmp = tempfile::tempdir().expect("tmpdir");
299
+ let root = tmp.path();
300
+ std::fs::write(root.join("hello.txt"), "hello\n").unwrap();
301
+
302
+ manifest_tracker_generate(Some(root)).expect("gen ok");
303
+ let raw = std::fs::read_to_string(root.join(".file-manifest.json")).unwrap();
304
+ let m: serde_json::Value = serde_json::from_str(&raw).unwrap();
305
+ let files = m["files"].as_object().unwrap();
306
+ assert_eq!(
307
+ files["hello.txt"].as_str().unwrap(),
308
+ "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
309
+ );
310
+ }
311
+ }
@@ -81,8 +81,8 @@ mod tests {
81
81
  // a partial bump (e.g. VERSION updated but Cargo.toml not)
82
82
  // even if all sources agree on the wrong value.
83
83
  assert_eq!(
84
- version_txt, "1.1.2",
85
- "expected VERSION to be 1.1.2; got {}",
84
+ version_txt, "1.2.0",
85
+ "expected VERSION to be 1.2.0; got {}",
86
86
  version_txt
87
87
  );
88
88
  }
@@ -0,0 +1,104 @@
1
+ //! E2E validator gate test for the `/compass:check` workflow.
2
+ //!
3
+ //! Port of `tests/integration/e2e_check_validates_prd.sh`. Exercises the same
4
+ //! CLI call that `check.md`'s Step 3b invokes:
5
+ //!
6
+ //! compass-cli validate prd <path>
7
+ //!
8
+ //! Covers REQ-02 (PRD taste gate: R-FLOW + R-XREF). Runs five scenarios —
9
+ //! one good PRD, two R-FLOW violations (prose + bullet), one R-XREF dangling,
10
+ //! one R-XREF valid — asserting the gate blocks on bad input and passes on
11
+ //! good input.
12
+
13
+ #[path = "e2e_common.rs"]
14
+ mod e2e_common;
15
+
16
+ use e2e_common::{fixture_root, run_cli};
17
+
18
+ #[test]
19
+ fn good_flow_passes() {
20
+ let path = fixture_root().join("prd_good_flow.md");
21
+ let out = run_cli(&["validate", "prd", path.to_str().unwrap()]);
22
+ assert!(
23
+ out.status.success(),
24
+ "expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
25
+ out.status,
26
+ String::from_utf8_lossy(&out.stdout),
27
+ String::from_utf8_lossy(&out.stderr)
28
+ );
29
+ let stdout = String::from_utf8_lossy(&out.stdout);
30
+ let v: serde_json::Value = serde_json::from_str(&stdout)
31
+ .unwrap_or_else(|e| panic!("parse json: {e}\nstdout: {stdout}"));
32
+ assert_eq!(v["ok"], true, "expected ok=true, got {}", stdout);
33
+ }
34
+
35
+ #[test]
36
+ fn bad_flow_prose_fails() {
37
+ let path = fixture_root().join("prd_bad_flow_prose.md");
38
+ let out = run_cli(&["validate", "prd", path.to_str().unwrap()]);
39
+ assert!(
40
+ !out.status.success(),
41
+ "expected non-zero exit, got 0\nstdout: {}",
42
+ String::from_utf8_lossy(&out.stdout)
43
+ );
44
+ let stdout = String::from_utf8_lossy(&out.stdout);
45
+ let stderr = String::from_utf8_lossy(&out.stderr);
46
+ let combined = format!("{stdout}{stderr}");
47
+ assert!(
48
+ combined.contains("R-FLOW"),
49
+ "expected R-FLOW violation, got stdout: {stdout}\nstderr: {stderr}"
50
+ );
51
+ }
52
+
53
+ #[test]
54
+ fn bad_flow_bullet_fails() {
55
+ let path = fixture_root().join("prd_bad_flow_bullet.md");
56
+ let out = run_cli(&["validate", "prd", path.to_str().unwrap()]);
57
+ assert!(
58
+ !out.status.success(),
59
+ "expected non-zero exit, got 0\nstdout: {}",
60
+ String::from_utf8_lossy(&out.stdout)
61
+ );
62
+ let stdout = String::from_utf8_lossy(&out.stdout);
63
+ let stderr = String::from_utf8_lossy(&out.stderr);
64
+ let combined = format!("{stdout}{stderr}");
65
+ assert!(
66
+ combined.contains("R-FLOW"),
67
+ "expected R-FLOW violation, got stdout: {stdout}\nstderr: {stderr}"
68
+ );
69
+ }
70
+
71
+ #[test]
72
+ fn xref_dangling_fails() {
73
+ let path = fixture_root().join("prd_xref_dangling.md");
74
+ let out = run_cli(&["validate", "prd", path.to_str().unwrap()]);
75
+ assert!(
76
+ !out.status.success(),
77
+ "expected non-zero exit, got 0\nstdout: {}",
78
+ String::from_utf8_lossy(&out.stdout)
79
+ );
80
+ let stdout = String::from_utf8_lossy(&out.stdout);
81
+ let stderr = String::from_utf8_lossy(&out.stderr);
82
+ let combined = format!("{stdout}{stderr}");
83
+ assert!(
84
+ combined.contains("R-XREF"),
85
+ "expected R-XREF violation, got stdout: {stdout}\nstderr: {stderr}"
86
+ );
87
+ }
88
+
89
+ #[test]
90
+ fn xref_valid_passes() {
91
+ let path = fixture_root().join("prd_xref_valid.md");
92
+ let out = run_cli(&["validate", "prd", path.to_str().unwrap()]);
93
+ assert!(
94
+ out.status.success(),
95
+ "expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
96
+ out.status,
97
+ String::from_utf8_lossy(&out.stdout),
98
+ String::from_utf8_lossy(&out.stderr)
99
+ );
100
+ let stdout = String::from_utf8_lossy(&out.stdout);
101
+ let v: serde_json::Value = serde_json::from_str(&stdout)
102
+ .unwrap_or_else(|e| panic!("parse json: {e}\nstdout: {stdout}"));
103
+ assert_eq!(v["ok"], true, "expected ok=true, got {}", stdout);
104
+ }
@@ -0,0 +1,217 @@
1
+ //! Shared helpers for `cli/tests/e2e_*.rs` integration test binaries.
2
+ //!
3
+ //! Cargo treats every file directly under `cli/tests/` as its own integration
4
+ //! test binary. Because of that, this file is NOT a library — consumers pull
5
+ //! it in with a path-based module declaration:
6
+ //!
7
+ //! ```ignore
8
+ //! #[path = "e2e_common.rs"] mod e2e_common;
9
+ //! use e2e_common::{HomeGuard, tmp_project, run_cli, run_cli_ok, run_cli_err, fixture_root};
10
+ //! ```
11
+ //!
12
+ //! This avoids the `tests/common/mod.rs` convention (which Cargo treats
13
+ //! inconsistently on some platforms) while still giving every binary its own
14
+ //! copy of the helpers compiled against its own crate graph.
15
+ //!
16
+ //! Each integration-test binary is its own OS process, so cross-binary races
17
+ //! on `$HOME` cannot happen — but tests WITHIN a single binary run on
18
+ //! multiple threads by default, so `HOME_GUARD` serializes them.
19
+
20
+ #![allow(dead_code)]
21
+
22
+ use std::path::{Path, PathBuf};
23
+ use std::sync::Mutex;
24
+
25
+ /// Process-global lock for tests that mutate `$HOME`. Serializes tests within
26
+ /// one integration-test binary; cross-binary isolation is handled by Cargo
27
+ /// running each binary in its own process.
28
+ pub static HOME_GUARD: Mutex<()> = Mutex::new(());
29
+
30
+ /// RAII guard: locks `HOME_GUARD`, swaps `$HOME` to a fresh tempdir, and
31
+ /// restores the previous `$HOME` (and best-effort cwd) on drop.
32
+ pub struct HomeGuard {
33
+ pub tmp: tempfile::TempDir,
34
+ prev_home: Option<std::ffi::OsString>,
35
+ prev_cwd: Option<PathBuf>,
36
+ // Held for the lifetime of the guard. `'static` is sound because
37
+ // `HOME_GUARD` is a `static`.
38
+ _lock: std::sync::MutexGuard<'static, ()>,
39
+ }
40
+
41
+ impl HomeGuard {
42
+ /// Acquire the global HOME lock, create a fresh tempdir, and point
43
+ /// `$HOME` at it. Panics on failure — these are test helpers.
44
+ pub fn new() -> Self {
45
+ // Recover from a poisoned mutex: a prior test panicked while holding
46
+ // it, but the data we guard (a `()`) is fine to reuse.
47
+ let lock = HOME_GUARD.lock().unwrap_or_else(|e| e.into_inner());
48
+
49
+ let prev_home = std::env::var_os("HOME");
50
+ let prev_cwd = std::env::current_dir().ok();
51
+
52
+ let tmp = tempfile::tempdir().expect("create tempdir for HomeGuard");
53
+ std::env::set_var("HOME", tmp.path());
54
+
55
+ HomeGuard {
56
+ tmp,
57
+ prev_home,
58
+ prev_cwd,
59
+ _lock: lock,
60
+ }
61
+ }
62
+
63
+ /// Path of the tempdir now pointed at by `$HOME`.
64
+ pub fn home_path(&self) -> &Path {
65
+ self.tmp.path()
66
+ }
67
+ }
68
+
69
+ impl Default for HomeGuard {
70
+ fn default() -> Self {
71
+ Self::new()
72
+ }
73
+ }
74
+
75
+ impl Drop for HomeGuard {
76
+ fn drop(&mut self) {
77
+ // Restore `$HOME` first so any Drop-time lookups see a sane value.
78
+ match self.prev_home.take() {
79
+ Some(v) => std::env::set_var("HOME", v),
80
+ None => std::env::remove_var("HOME"),
81
+ }
82
+ // Best-effort: restore cwd so tests that cd'd into the tempdir don't
83
+ // leave a dangling cwd after the tempdir is unlinked.
84
+ if let Some(cwd) = self.prev_cwd.take() {
85
+ let _ = std::env::set_current_dir(cwd);
86
+ }
87
+ // `_lock` drops automatically, releasing HOME_GUARD.
88
+ }
89
+ }
90
+
91
+ /// Resolve `cli/tests/fixtures/...` as an absolute path.
92
+ pub fn fixture_root() -> PathBuf {
93
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
94
+ .join("tests")
95
+ .join("fixtures")
96
+ }
97
+
98
+ /// Create a tmp dir and seed a minimal valid Compass project rooted at it.
99
+ ///
100
+ /// Layout:
101
+ /// - `<root>/.compass/.state/config.json` — v1.2.0 skeleton
102
+ /// - `<root>/prd/` (empty)
103
+ /// - `<root>/epics/` (empty)
104
+ ///
105
+ /// Returns `(TempDir guard, project_root)`. Keep the `TempDir` alive for the
106
+ /// duration of the test — dropping it deletes the directory.
107
+ pub fn tmp_project(name: &str) -> (tempfile::TempDir, PathBuf) {
108
+ let tmp = tempfile::tempdir().expect("create tempdir for tmp_project");
109
+ let root = tmp.path().to_path_buf();
110
+
111
+ let state_dir = root.join(".compass").join(".state");
112
+ std::fs::create_dir_all(&state_dir).expect("mkdir .compass/.state");
113
+ std::fs::create_dir_all(root.join("prd")).expect("mkdir prd");
114
+ std::fs::create_dir_all(root.join("epics")).expect("mkdir epics");
115
+
116
+ // Build via serde_json so the name is safely escaped rather than
117
+ // string-interpolated into JSON.
118
+ let cfg = serde_json::json!({
119
+ "version": "1.2.0",
120
+ "project": { "name": name, "po": "@t" },
121
+ "lang": "en",
122
+ "spec_lang": "en",
123
+ "mode": "standalone",
124
+ "prefix": "PX",
125
+ "domain": "ard",
126
+ "output_paths": {
127
+ "prd": "prd",
128
+ "story": "epics/{EPIC}/user-stories",
129
+ "epic": "epics"
130
+ },
131
+ "naming": { "prd": "{slug}.md" }
132
+ });
133
+
134
+ std::fs::write(
135
+ state_dir.join("config.json"),
136
+ serde_json::to_vec_pretty(&cfg).expect("serialize config"),
137
+ )
138
+ .expect("write config.json");
139
+
140
+ (tmp, root)
141
+ }
142
+
143
+ /// Absolute path to the release `compass-cli` binary.
144
+ fn compass_cli_path() -> PathBuf {
145
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
146
+ .join("target")
147
+ .join("release")
148
+ .join("compass-cli")
149
+ }
150
+
151
+ /// Ensure the release binary exists; build it if not.
152
+ fn ensure_release_binary() -> PathBuf {
153
+ let bin = compass_cli_path();
154
+ if bin.exists() {
155
+ return bin;
156
+ }
157
+
158
+ let manifest_dir = env!("CARGO_MANIFEST_DIR");
159
+ let status = std::process::Command::new("cargo")
160
+ .args(["build", "--release", "--bin", "compass-cli"])
161
+ .current_dir(manifest_dir)
162
+ .status()
163
+ .expect("spawn `cargo build --release`");
164
+ assert!(
165
+ status.success(),
166
+ "`cargo build --release --bin compass-cli` failed with {status}"
167
+ );
168
+
169
+ assert!(
170
+ bin.exists(),
171
+ "release binary still missing after build: {}",
172
+ bin.display()
173
+ );
174
+ bin
175
+ }
176
+
177
+ /// Spawn the release `compass-cli` binary with the given args and return its
178
+ /// raw `Output`. Builds the binary on demand the first time it's needed.
179
+ pub fn run_cli(args: &[&str]) -> std::process::Output {
180
+ let bin = ensure_release_binary();
181
+ std::process::Command::new(&bin)
182
+ .args(args)
183
+ .output()
184
+ .unwrap_or_else(|e| panic!("spawn {}: {e}", bin.display()))
185
+ }
186
+
187
+ /// Run the CLI, assert exit code 0, and return stdout as a `String`.
188
+ /// Panics with stdout+stderr on non-zero exit — test failure output is
189
+ /// useless without both streams.
190
+ pub fn run_cli_ok(args: &[&str]) -> String {
191
+ let out = run_cli(args);
192
+ if !out.status.success() {
193
+ panic!(
194
+ "run_cli_ok({:?}) failed: status={:?}\n--- stdout ---\n{}\n--- stderr ---\n{}",
195
+ args,
196
+ out.status,
197
+ String::from_utf8_lossy(&out.stdout),
198
+ String::from_utf8_lossy(&out.stderr),
199
+ );
200
+ }
201
+ String::from_utf8(out.stdout).expect("stdout not valid UTF-8")
202
+ }
203
+
204
+ /// Run the CLI, assert exit code non-zero, and return stderr as a `String`.
205
+ /// Panics on unexpected success.
206
+ pub fn run_cli_err(args: &[&str]) -> String {
207
+ let out = run_cli(args);
208
+ if out.status.success() {
209
+ panic!(
210
+ "run_cli_err({:?}) unexpectedly succeeded\n--- stdout ---\n{}\n--- stderr ---\n{}",
211
+ args,
212
+ String::from_utf8_lossy(&out.stdout),
213
+ String::from_utf8_lossy(&out.stderr),
214
+ );
215
+ }
216
+ String::from_utf8(out.stderr).expect("stderr not valid UTF-8")
217
+ }