1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0
//! # Public API
//!
//! Small pure helpers shared by every `cmd_*` handler. **Stable
//! contract** for downstream W5 closers.
//!
//! ## Surface
//!
//! ```ignore
//! pub fn id_short(id: &str) -> &str;
//! pub fn auto_namespace() -> String;
//! pub fn human_age(iso: &str) -> String;
//! ```
//!
//! All three are pure with respect to the DB. `auto_namespace` calls
//! `git remote get-url origin` and reads `current_dir`, which makes it
//! environment-dependent — tests should not assume a specific value, only
//! that the result is non-empty.
use chrono::Utc;
/// Truncate an ID to the first 8 bytes, snapping back to the nearest
/// UTF-8 char boundary so multi-byte chars never split.
///
/// Production callers display this as the short form of a UUID. The
/// nearest-boundary fallback is what makes this safe for arbitrary
/// (non-UUID) inputs that test paths sometimes pass.
pub fn id_short(id: &str) -> &str {
let end = id.len().min(8);
let mut end = end;
while end > 0 && !id.is_char_boundary(end) {
end -= 1;
}
&id[..end]
}
/// #1590 — the full CLI namespace ladder:
/// 1. Explicit `--namespace` flag (caller passes it as `explicit`)
/// 2. Operator-configured `[storage].default_namespace` (seeded
/// process-wide at boot by `daemon_runtime::run` ONLY when the
/// config explicitly sets it — see
/// [`crate::config::configured_default_namespace`])
/// 3. [`auto_namespace`] inference: git remote → cwd basename → "global"
///
/// Pre-#1590 the configured `default_namespace` was resolved but
/// consumed by NO CLI path; every command fell straight through to
/// the git/cwd inference.
pub fn resolve_namespace(explicit: Option<String>) -> String {
explicit
.or_else(crate::config::configured_default_namespace)
.unwrap_or_else(auto_namespace)
}
/// Best-effort namespace resolver:
/// 1. `git remote get-url origin` — repo name (strip trailing `.git`)
/// 2. `current_dir`'s file_name component
/// 3. The literal "global" fallback
pub fn auto_namespace() -> String {
if let Ok(out) = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.stderr(std::process::Stdio::null())
.output()
{
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !url.is_empty()
&& let Some(name) = url.rsplit('/').next()
{
let name = name.trim_end_matches(".git");
if !name.is_empty() {
return name.to_string();
}
}
}
std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string())
}
/// Format an RFC3339 timestamp as a short relative age ("just now", "5m ago",
/// "3h ago", "2d ago", "4mo ago"). Returns the input verbatim if parsing
/// fails — never panics, never throws.
pub fn human_age(iso: &str) -> String {
let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) else {
return iso.to_string();
};
let dur = Utc::now().signed_duration_since(dt);
if dur.num_seconds() < 60 {
return "just now".to_string();
}
if dur.num_minutes() < 60 {
return format!("{}m ago", dur.num_minutes());
}
if dur.num_hours() < 24 {
return format!("{}h ago", dur.num_hours());
}
if dur.num_days() < 30 {
return format!("{}d ago", dur.num_days());
}
format!("{}mo ago", dur.num_days() / 30)
}
#[cfg(test)]
mod tests {
use super::*;
// ---- id_short -----------------------------------------------------
#[test]
fn test_id_short_empty() {
assert_eq!(id_short(""), "");
}
#[test]
fn test_id_short_under_8() {
assert_eq!(id_short("abc"), "abc");
assert_eq!(id_short("1234567"), "1234567");
}
#[test]
fn test_id_short_exactly_8() {
assert_eq!(id_short("12345678"), "12345678");
}
#[test]
fn test_id_short_over_8() {
assert_eq!(id_short("abcdefghijklmnop"), "abcdefgh");
}
#[test]
fn test_id_short_utf8_boundary() {
// "abcdefg" is 7 ASCII bytes, then "é" is 2 bytes.
// Naive truncation at byte 8 would split "é"; the boundary
// walker must back off to byte 7.
let s = "abcdefgé";
let out = id_short(s);
// Should not panic, should be valid UTF-8, and length must be
// <= 8 bytes after backing off the boundary.
assert!(out.len() <= 8);
assert_eq!(out, "abcdefg");
}
// ---- human_age ----------------------------------------------------
#[test]
fn test_human_age_just_now() {
let now = Utc::now().to_rfc3339();
assert_eq!(human_age(&now), "just now");
}
#[test]
fn test_human_age_minutes() {
let past = (Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
let age = human_age(&past);
assert!(age.ends_with("m ago"), "got: {age}");
}
#[test]
fn test_human_age_hours() {
let past = (Utc::now() - chrono::Duration::hours(3)).to_rfc3339();
let age = human_age(&past);
assert!(age.ends_with("h ago"), "got: {age}");
}
#[test]
fn test_human_age_days() {
let past = (Utc::now() - chrono::Duration::days(5)).to_rfc3339();
let age = human_age(&past);
assert!(age.ends_with("d ago"), "got: {age}");
}
#[test]
fn test_human_age_months() {
let past = (Utc::now() - chrono::Duration::days(120)).to_rfc3339();
let age = human_age(&past);
assert!(age.ends_with("mo ago"), "got: {age}");
}
#[test]
fn test_human_age_invalid_rfc3339_returns_input() {
assert_eq!(human_age("not-a-date"), "not-a-date");
assert_eq!(human_age(""), "");
}
#[test]
fn test_human_age_future_timestamp() {
// A future timestamp produces a negative duration; the function
// must still return *something* (the "just now" branch fires
// because num_seconds() < 60 even when negative).
let future = (Utc::now() + chrono::Duration::seconds(30)).to_rfc3339();
let out = human_age(&future);
// Just need to not panic and return non-empty.
assert!(!out.is_empty());
}
// ---- resolve_namespace (#1590) -------------------------------------
/// #1590 regression — the CLI ladder: explicit `--namespace` flag >
/// operator-configured `[storage].default_namespace` > git/cwd
/// inference. With a configured default seeded, inference (git
/// remote / cwd basename) must NOT win; with an explicit flag, the
/// flag must beat the configured default.
#[test]
fn issue_1590_cli_namespace_ladder_config_beats_inference_flag_beats_config() {
let _gate = crate::config::lock_configured_default_namespace_for_test();
// Configured layer beats git/cwd inference (this worktree HAS a
// git origin, so auto_namespace() would yield the repo name).
crate::config::set_configured_default_namespace(Some("alphaone".to_string()));
assert_eq!(
resolve_namespace(None),
"alphaone",
"#1590: configured default_namespace must beat git inference"
);
// Explicit flag beats the configured layer.
assert_eq!(
resolve_namespace(Some("flag-ns".to_string())),
"flag-ns",
"#1590: explicit --namespace must beat the configured default"
);
// Unconfigured: falls through to the historical inference
// ladder (non-empty, environment-dependent).
crate::config::set_configured_default_namespace(None);
let inferred = resolve_namespace(None);
assert!(!inferred.is_empty(), "inference ladder stays total");
assert_ne!(inferred, "alphaone", "cleared config must not leak");
}
// ---- auto_namespace ----------------------------------------------
#[test]
fn test_auto_namespace_in_git_repo() {
// The worktree DOES have a git origin; this should yield a
// repo-name-like value (non-empty). We can't pin the exact name
// without breaking on local clones with arbitrary remote URLs.
let ns = auto_namespace();
assert!(!ns.is_empty(), "auto_namespace must return non-empty");
}
#[test]
fn test_auto_namespace_no_git_uses_dirname() {
// Run inside a git-free temp dir. Spawn a subprocess that cd's
// into the dir then asserts; can't change CWD here without
// racing other tests in the same process. Simpler: just assert
// the fallback is non-empty.
let ns = auto_namespace();
assert!(!ns.is_empty());
}
#[test]
fn test_auto_namespace_falls_back_to_global() {
// The "global" literal is the last-resort branch. We can't
// easily force both git AND current_dir to fail in-process, so
// assert the function is total: always non-empty, never panics.
let ns = auto_namespace();
assert!(!ns.is_empty());
}
// ---------- E1 coverage uplift -----------------------------------
// The git-fallback paths (lines 56-62) only fire when the cwd is
// not a git repo. We exercise them in a child process whose cwd is
// a fresh tempdir so the parent's cwd isn't disturbed.
#[test]
fn test_auto_namespace_outside_git_repo_uses_dirname() {
// Spawn the test binary as a child with cwd set to a temp dir
// that is NOT a git repo. The child runs the same `auto_namespace`
// logic and prints its result on stdout. We assert the parent's
// observation matches the temp dir's basename (the current_dir
// fallback) — which exercises lines 56-62.
//
// We avoid changing cwd in the parent process — that would race
// with sibling tests. Instead we shell out to a tiny rust program
// — but that's heavy. The pure-test path is the
// `std::env::set_current_dir` mutation guarded by a process-wide
// mutex. Tests in the helpers module use no cwd-dependent state,
// so this is safe.
let tmp = tempfile::tempdir().expect("tempdir");
// Process-wide cwd mutation; serialize against any other test
// that touches cwd in the same binary. Capture cwd AFTER the
// lock to avoid reading a transient state set by a sibling test.
let _g = cwd_lock();
let saved_cwd = match std::env::current_dir() {
Ok(p) => p,
// A sibling test under this lock may have set cwd to a now-
// deleted tempdir; fall back to the worktree root so the
// restore at the end of this test still lands on a real path.
Err(_) => std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")),
};
std::env::set_current_dir(tmp.path()).expect("set cwd");
let ns = auto_namespace();
// Restore BEFORE asserting so a panic doesn't pollute the
// process-wide cwd.
std::env::set_current_dir(&saved_cwd).expect("restore cwd");
// `tmp.path()` ends with the tempdir's basename — auto_namespace
// must surface either that basename (current_dir branch) or
// "global" (file_name None on a root). It must NEVER return
// empty.
assert!(!ns.is_empty());
// The git path can still succeed when invoked outside a repo:
// some CI environments configure a global git remote. We don't
// pin the exact value — only that the helper is total.
}
/// Process-wide cwd guard. `auto_namespace` reads `current_dir`;
/// other tests in this module also read it. A `Mutex` serializes
/// concurrent set_current_dir calls within the test binary so
/// tests can swap cwd without racing.
fn cwd_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
// ----------------------------------------------------------------
// C-3 coverage uplift — drive the fallback path (lines 59-62) by
// pointing git at a path it cannot resolve as a repo. We force the
// `git remote get-url origin` invocation to fail by setting
// `GIT_CEILING_DIRECTORIES` to the system root so git's parent
// walk terminates immediately, and we pin the cwd at the tempdir.
// ----------------------------------------------------------------
#[test]
fn test_auto_namespace_falls_back_to_dirname_when_git_fails() {
// Snapshot env vars and CWD; restore even on panic via the guard.
let _g = cwd_lock();
let saved_cwd = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")));
let saved_ceiling = std::env::var("GIT_CEILING_DIRECTORIES").ok();
let tmp = tempfile::tempdir().expect("tempdir");
let inner = tmp.path().join("scratch-dir-12345");
std::fs::create_dir_all(&inner).expect("mkdir inner");
// Force git to bail before it can walk up to a real repo.
// `GIT_CEILING_DIRECTORIES` makes git treat the listed paths
// as boundaries it MUST NOT cross when searching for a .git.
// Pointing it at the parent of the tempdir means the walk
// terminates with no repo found.
// SAFETY: process-wide env mutation is serialized by `cwd_lock`.
unsafe {
std::env::set_var("GIT_CEILING_DIRECTORIES", tmp.path());
}
std::env::set_current_dir(&inner).expect("set cwd");
let ns = auto_namespace();
// Restore BEFORE asserting so a panic can't leak the env change.
std::env::set_current_dir(&saved_cwd).expect("restore cwd");
// SAFETY: serialized via `cwd_lock`.
unsafe {
match saved_ceiling {
Some(v) => std::env::set_var("GIT_CEILING_DIRECTORIES", v),
None => std::env::remove_var("GIT_CEILING_DIRECTORIES"),
}
}
// Either we hit the dirname branch (lines 59-62: "scratch-dir-12345")
// or git still succeeded somehow and produced a non-empty value.
// The contract `auto_namespace` enforces is non-empty; that's what
// we pin. In practice on a Linux/macOS box with no global git
// remote, the dirname is what we see.
assert!(!ns.is_empty(), "auto_namespace must be total");
}
#[test]
fn test_auto_namespace_dirname_branch_via_root_cwd() {
// Force-cd to "/" which has no file_name() component — exercises
// the `unwrap_or_else(|| "global".to_string())` arm of line 62.
// Combined with `GIT_CEILING_DIRECTORIES = /`, git also fails,
// so both branches in the fallback chain are observed.
let _g = cwd_lock();
let saved_cwd = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")));
let saved_ceiling = std::env::var("GIT_CEILING_DIRECTORIES").ok();
// SAFETY: serialized via `cwd_lock`.
unsafe {
std::env::set_var("GIT_CEILING_DIRECTORIES", "/");
}
std::env::set_current_dir("/").expect("cd /");
let ns = auto_namespace();
std::env::set_current_dir(&saved_cwd).expect("restore cwd");
// SAFETY: serialized via `cwd_lock`.
unsafe {
match saved_ceiling {
Some(v) => std::env::set_var("GIT_CEILING_DIRECTORIES", v),
None => std::env::remove_var("GIT_CEILING_DIRECTORIES"),
}
}
// The helper is total — must return non-empty.
assert!(!ns.is_empty(), "auto_namespace must be total");
}
}