返回 CodeWhale
utils.rs
根目录 / crates / tui / src / utils.rs
1 //! Utility helpers shared across the `DeepSeek` CLI.
2
3 use std::fs;
4 use std::io::Write;
5 #[cfg(unix)]
6 use std::os::unix::fs::MetadataExt;
7 use std::path::{Path, PathBuf};
8 use std::process::Command;
9
10 use anyhow::Result;
11 use codewhale_models::{ContentBlock, Message};
12 use ignore::WalkBuilder;
13 use std::io;
14
15 /// A writer that counts bytes written without storing them.
16 pub(crate) struct CountingWriter {
17 count: usize,
18 }
19
20 impl CountingWriter {
21 pub(crate) fn new() -> Self {
22 Self { count: 0 }
23 }
24
25 pub(crate) fn count(&self) -> usize {
26 self.count
27 }
28 }
29
30 impl io::Write for CountingWriter {
31 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
32 self.count += buf.len();
33 Ok(buf.len())
34 }
35
36 fn flush(&mut self) -> io::Result<()> {
37 Ok(())
38 }
39 }
40
41 const LOG_FINGERPRINT_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
42 const LOG_FINGERPRINT_PRIME: u64 = 0x0000_0100_0000_01b3;
43
44 /// Return a stable, non-reversible log label for an identifier.
45 ///
46 /// This is meant for correlation in diagnostics where the raw value may be a
47 /// session token, remote protocol session id, or other bearer-like handle.
48 #[must_use]
49 pub fn redacted_identifier_for_log(identifier: &str) -> String {
50 if identifier.is_empty() {
51 return "<redacted:empty>".to_string();
52 }
53
54 let mut hash = LOG_FINGERPRINT_OFFSET_BASIS;
55 for byte in identifier.as_bytes() {
56 hash ^= u64::from(*byte);
57 hash = hash.wrapping_mul(LOG_FINGERPRINT_PRIME);
58 }
59 hash ^= identifier.len() as u64;
60 hash = hash.wrapping_mul(LOG_FINGERPRINT_PRIME);
61
62 format!("<redacted:{hash:016x}>")
63 }
64
65 #[cfg(windows)]
66 pub(crate) fn suppress_console_window(cmd: &mut Command) {
67 use std::os::windows::process::CommandExt;
68
69 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
70 cmd.creation_flags(CREATE_NO_WINDOW);
71 }
72
73 #[cfg(not(windows))]
74 pub(crate) fn suppress_console_window(_cmd: &mut Command) {}
75
76 #[cfg(windows)]
77 pub(crate) fn suppress_tokio_console_window(cmd: &mut tokio::process::Command) {
78 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
79 cmd.creation_flags(CREATE_NO_WINDOW);
80 }
81
82 #[cfg(not(windows))]
83 pub(crate) fn suppress_tokio_console_window(_cmd: &mut tokio::process::Command) {}
84
85 // === Project Mapping Helpers ===
86
87 /// Identify if a file is a "key" file for project identification.
88 #[must_use]
89 pub fn is_key_file(path: &Path) -> bool {
90 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
91 return false;
92 };
93
94 matches!(
95 file_name.to_lowercase().as_str(),
96 "cargo.toml"
97 | "package.json"
98 | "requirements.txt"
99 | "build.gradle"
100 | "pom.xml"
101 | "readme.md"
102 | "agents.md"
103 | "claude.md"
104 | "makefile"
105 | "dockerfile"
106 | "main.rs"
107 | "lib.rs"
108 | "index.js"
109 | "index.ts"
110 | "app.py"
111 )
112 }
113
114 /// Generate a high-level summary of the project based on key files.
115 ///
116 /// Output is byte-stable across calls: `WalkBuilder` doesn't sort siblings
117 /// (the OS readdir order leaks through), so the joined `key_files` list
118 /// would otherwise reorder run-to-run on filesystems that don't pre-sort.
119 /// Only matters when the workspace has no `AGENTS.md` / `CLAUDE.md`, since
120 /// the system prompt routes through `ProjectContext::as_system_block` first
121 /// and only falls back here when no project-context document exists.
122 #[must_use]
123 pub fn summarize_project(root: &Path) -> String {
124 let mut key_files = Vec::new();
125
126 let mut builder = WalkBuilder::new(root);
127 builder.hidden(false).follow_links(false).max_depth(Some(2));
128 let walker = builder.build();
129
130 for entry in walker {
131 let entry = match entry {
132 Ok(entry) => entry,
133 Err(_) => continue,
134 };
135 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
136 continue;
137 }
138 if is_key_file(entry.path())
139 && let Ok(rel) = entry.path().strip_prefix(root)
140 {
141 key_files.push(rel.to_string_lossy().to_string());
142 }
143 }
144
145 key_files.sort();
146
147 if key_files.is_empty() {
148 return "Unknown project type".to_string();
149 }
150
151 let mut types = Vec::new();
152 if key_files
153 .iter()
154 .any(|f| f.to_lowercase().contains("cargo.toml"))
155 {
156 types.push("Rust");
157 }
158 if key_files
159 .iter()
160 .any(|f| f.to_lowercase().contains("package.json"))
161 {
162 types.push("JavaScript/Node.js");
163 }
164 if key_files
165 .iter()
166 .any(|f| f.to_lowercase().contains("requirements.txt"))
167 {
168 types.push("Python");
169 }
170
171 if types.is_empty() {
172 format!("Project with key files: {}", key_files.join(", "))
173 } else {
174 format!("A {} project", types.join(" and "))
175 }
176 }
177
178 /// Generate a tree-like view of the project structure.
179 ///
180 /// Sibling order is fixed by sorting collected paths — the underlying
181 /// `WalkBuilder` follows the OS readdir order, which is non-deterministic
182 /// across filesystems. Sorting by full path preserves the tree shape (a
183 /// directory still precedes its children because `"src" < "src/lib.rs"`)
184 /// while making the rendered output byte-stable across runs.
185 #[must_use]
186 pub fn project_tree(root: &Path, max_depth: usize, follow_symlinks: bool) -> String {
187 let mut entries: Vec<(PathBuf, bool)> = Vec::new();
188
189 let mut builder = WalkBuilder::new(root);
190 builder
191 .hidden(false)
192 .follow_links(follow_symlinks)
193 .max_depth(Some(max_depth + 1));
194
195 for entry in builder.build().flatten() {
196 if entry.file_type().is_some_and(|ft| ft.is_symlink()) && !follow_symlinks {
197 continue;
198 }
199 let depth = entry.depth();
200 if depth == 0 || depth > max_depth {
201 continue;
202 }
203 let rel_path = entry
204 .path()
205 .strip_prefix(root)
206 .unwrap_or(entry.path())
207 .to_path_buf();
208 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
209 entries.push((rel_path, is_dir));
210 }
211
212 entries.sort_by(|a, b| a.0.cmp(&b.0));
213
214 let mut tree_lines = Vec::with_capacity(entries.len());
215 for (rel_path, is_dir) in entries {
216 let depth = rel_path.components().count();
217 let indent = " ".repeat(depth.saturating_sub(1));
218 let prefix = if is_dir { "DIR: " } else { "FILE: " };
219 tree_lines.push(format!(
220 "{}{}{}",
221 indent,
222 prefix,
223 rel_path.file_name().unwrap_or_default().to_string_lossy()
224 ));
225 }
226
227 tree_lines.join("\n")
228 }
229
230 // === Filesystem Helpers ===
231
232 /// Permission policy for atomic writes.
233 ///
234 /// - [`AtomicWritePermissions::Private`]: keep tempfile's owner-only defaults
235 /// (used for CodeWhale internal persistence such as session/history/trust).
236 /// - [`AtomicWritePermissions::Workspace`]: match ordinary workspace file
237 /// semantics — new files request mode `0666` (kernel applies umask); existing
238 /// files retain ordinary `rwx` bits (not setuid/setgid/sticky).
239 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
240 enum AtomicWritePermissions {
241 Private,
242 Workspace,
243 }
244
245 /// Atomically write `contents` to `path` using a temporary file + fsync + rename.
246 ///
247 /// Uses a **private** permission policy (Unix tempfile default `0600`). Prefer
248 /// [`write_atomic_workspace`] for user workspace source/config files.
249 ///
250 /// 1. Creates a `NamedTempFile` in the same directory as `path` (same filesystem).
251 /// 2. Writes `contents` to the temp file.
252 /// 3. Calls `sync_all()` on the temp file for durability.
253 /// 4. Atomically renames (persists) the temp file over `path`.
254 ///
255 /// On filesystems that support it (`ext4`, `apfs`, `ntfs`), the rename is
256 /// atomic — a concurrent reader sees either the old content or the new, never
257 /// a partial write. `sync_all` ensures the data is on stable storage before
258 /// the metadata change so an OS crash mid-rename doesn't lose data.
259 ///
260 /// # Errors
261 /// Returns `io::Error` if the parent directory cannot be determined, the temp
262 /// file cannot be created, the write fails, or the rename fails.
263 pub fn write_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> {
264 write_atomic_with_permissions(path, contents, AtomicWritePermissions::Private)
265 }
266
267 /// Atomically write `contents` to a **user workspace** path.
268 ///
269 /// On Unix:
270 /// - New files request creation mode `0666`; the OS applies the process umask
271 /// (same candidate mode as ordinary `std::fs::write`).
272 /// - Existing files keep ordinary permission bits (`mode & 0o777`), including
273 /// executable bits. setuid/setgid/sticky are intentionally not restored.
274 ///
275 /// On Windows this matches [`write_atomic`] (no POSIX mode simulation).
276 ///
277 /// # Errors
278 /// Same failure modes as [`write_atomic`].
279 pub fn write_atomic_workspace(path: &Path, contents: &[u8]) -> std::io::Result<()> {
280 // Hard-link guard (issue #5569): a workspace path that shares its inode
281 // with another name cannot be proven to stay inside the writable root by
282 // path checks. Atomic rename would replace the directory entry (leaving
283 // the outside link on the old inode), but that silently splits the pair
284 // and would not block a future non-atomic writer. Fail closed on both
285 // platforms that can count links.
286 if let Some(links) = hard_link_count(path)
287 && links > 1
288 {
289 return Err(std::io::Error::new(
290 std::io::ErrorKind::InvalidData,
291 format!(
292 "refusing to rewrite {}: the file has {links} hard links and path checks cannot prove the other links stay inside the workspace; copy it to a new name to break the link",
293 path.display(),
294 ),
295 ));
296 }
297 write_atomic_with_permissions(path, contents, AtomicWritePermissions::Workspace)
298 }
299
300 /// Hard-link count for an existing regular file, or `None` when the platform
301 /// cannot answer or the path is not a regular file.
302 ///
303 /// `None` means "unknown", never "one". A caller guarding against link
304 /// escapes must treat an unknown count as unguarded, not as safe.
305 #[cfg(unix)]
306 fn hard_link_count(path: &Path) -> Option<u64> {
307 let metadata = std::fs::metadata(path).ok()?;
308 metadata.is_file().then(|| metadata.nlink())
309 }
310
311 /// Windows counts links too — `std` does not expose it, but the Win32 call
312 /// that does is already used for the workspace `.env` guard in `lib.rs`.
313 /// Leaving this side unguarded meant a hard-linked file outside the
314 /// workspace was rewritable on Windows and refused on Unix, which is the
315 /// worse half of the platform to leave open.
316 ///
317 /// The handle is opened read-only and closed by `File`'s Drop, so this adds
318 /// one open/close on a path that is about to be rewritten anyway.
319 #[cfg(windows)]
320 fn hard_link_count(path: &Path) -> Option<u64> {
321 use std::os::windows::io::AsRawHandle;
322 use windows::Win32::Foundation::HANDLE;
323 use windows::Win32::Storage::FileSystem::{
324 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
325 };
326
327 let metadata = std::fs::metadata(path).ok()?;
328 if !metadata.is_file() {
329 return None;
330 }
331 let file = std::fs::File::open(path).ok()?;
332 let mut information = BY_HANDLE_FILE_INFORMATION::default();
333 // SAFETY: `file` owns a live kernel handle for the duration of the call
334 // and `information` stays writable across it. The call is synchronous and
335 // performs no path lookup or re-open.
336 unsafe {
337 GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information).ok()?;
338 }
339 Some(u64::from(information.nNumberOfLinks))
340 }
341
342 #[cfg(not(any(unix, windows)))]
343 fn hard_link_count(_path: &Path) -> Option<u64> {
344 None
345 }
346
347 /// Backoff before re-attempting a Windows atomic publication, or `None` when
348 /// `error` must be surfaced to the caller.
349 ///
350 /// Windows can briefly deny the rename that publishes a temporary file while
351 /// Defender, the indexer, or a concurrent reader still holds the source or the
352 /// destination without delete sharing. `MoveFileExW` then reports a sharing or
353 /// lock violation that clears on its own, while a real permission failure
354 /// repeats until the attempts run out.
355 ///
356 /// The ordinary and confined Fleet writers share this classification
357 /// and schedule, to tolerate brief sharing conflicts without letting
358 /// either path invent a broader retry of its own. Only the rename is
359 /// re-attempted: callers keep the temporary they already wrote and synced, so a
360 /// retry never rewrites bytes or widens the window in which data can be lost.
361 ///
362 /// The classification stays deliberately narrow. `ERROR_ALREADY_EXISTS` in
363 /// particular is a real answer for no-clobber publication — Fleet artifact
364 /// immutability depends on receiving it — so it is returned unchanged.
365 #[cfg(windows)]
366 pub(crate) fn windows_publish_retry_delay(
367 error: &std::io::Error,
368 attempt: usize,
369 ) -> Option<std::time::Duration> {
370 const MAX_PERSIST_ATTEMPTS: usize = 6;
371 // 5 ERROR_ACCESS_DENIED, 32 ERROR_SHARING_VIOLATION, 33 ERROR_LOCK_VIOLATION.
372 let transient = error.kind() == std::io::ErrorKind::PermissionDenied
373 || matches!(error.raw_os_error(), Some(5 | 32 | 33));
374 if !transient || attempt + 1 >= MAX_PERSIST_ATTEMPTS {
375 return None;
376 }
377 Some(std::time::Duration::from_millis(
378 10u64.saturating_mul(1u64 << attempt),
379 ))
380 }
381
382 fn write_atomic_with_permissions(
383 path: &Path,
384 contents: &[u8],
385 #[cfg_attr(not(unix), allow(unused_variables))] permission_policy: AtomicWritePermissions,
386 ) -> std::io::Result<()> {
387 let parent = path.parent().ok_or_else(|| {
388 std::io::Error::new(
389 std::io::ErrorKind::InvalidInput,
390 format!("path has no parent directory: {}", path.display()),
391 )
392 })?;
393
394 // Capture ordinary rwx bits before replacement. Use symlink_metadata so we
395 // do not follow links: an inaccessible or dangling symlink target must not
396 // abort the write — rename still replaces the directory entry, matching
397 // the pre-#4606 private write_atomic behavior. Symlink entries themselves
398 // are treated as "no mode to preserve" (new ordinary file after rename);
399 // only regular-file modes are restored. Mask with 0o777 so setuid/setgid/
400 // sticky are never restored after rewriting content.
401 #[cfg(unix)]
402 let existing_workspace_mode = if permission_policy == AtomicWritePermissions::Workspace {
403 match fs::symlink_metadata(path) {
404 Ok(metadata) if metadata.file_type().is_symlink() => None,
405 Ok(metadata) => {
406 use std::os::unix::fs::PermissionsExt;
407 Some(metadata.permissions().mode() & 0o777)
408 }
409 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
410 Err(err) => return Err(err),
411 }
412 } else {
413 None
414 };
415
416 // Use parent directory so the rename is on the same filesystem.
417 #[cfg(unix)]
418 let mut builder = tempfile::Builder::new();
419 #[cfg(not(unix))]
420 let builder = tempfile::Builder::new();
421
422 // New workspace files should behave like ordinary files opened with
423 // creation mode 0666. The kernel applies the inherited process umask.
424 // Do NOT chmod after create: set_permissions bypasses umask.
425 #[cfg(unix)]
426 if permission_policy == AtomicWritePermissions::Workspace && existing_workspace_mode.is_none() {
427 use std::os::unix::fs::PermissionsExt;
428 builder.permissions(fs::Permissions::from_mode(0o666));
429 }
430
431 // Reclaim our own strays before adding another (see the function docs).
432 // Private permission policy is also used for user-chosen destinations
433 // such as `/save <path>`; only sweep Codewhale-owned state/config dirs.
434 if permission_policy == AtomicWritePermissions::Private && is_codewhale_owned_state_dir(parent)
435 {
436 sweep_stale_atomic_write_temps(parent);
437 }
438
439 let mut tmp = builder.tempfile_in(parent)?;
440 std::io::Write::write_all(&mut tmp, contents)?;
441
442 // Atomic replacement creates a new inode. Restore ordinary access /
443 // executable bits of an existing workspace file before persisting.
444 #[cfg(unix)]
445 if let Some(mode) = existing_workspace_mode {
446 use std::os::unix::fs::PermissionsExt;
447 tmp.as_file()
448 .set_permissions(fs::Permissions::from_mode(mode))?;
449 }
450
451 tmp.as_file().sync_all()?;
452 #[cfg(windows)]
453 {
454 // Keep the already-synced tempfile and retry only the transient Win32
455 // sharing/lock failures; permanent permission errors still surface.
456 let mut pending = tmp;
457 let mut attempt = 0;
458 loop {
459 match pending.persist(path) {
460 Ok(_) => break,
461 Err(err) => {
462 let Some(backoff) = windows_publish_retry_delay(&err.error, attempt) else {
463 return Err(err.error);
464 };
465 pending = err.file;
466 std::thread::sleep(backoff);
467 attempt += 1;
468 }
469 }
470 }
471 }
472 #[cfg(not(windows))]
473 tmp.persist(path)?;
474 // Fsync the parent directory so the rename (the new directory entry) is
475 // itself durable — otherwise a power loss right after the rename can lose
476 // it even though the file data was synced, silently dropping a
477 // crash-recovery checkpoint. Best-effort: not all platforms permit
478 // opening a directory for sync, so a failure here is not fatal.
479 if let Ok(dir) = std::fs::File::open(parent) {
480 let _ = dir.sync_all();
481 }
482 Ok(())
483 }
484
485 /// True when `dir` is under `$CODEWHALE_HOME` / `~/.codewhale`, or the ambient
486 /// `~/.deepseek` legacy root when that root is still in play.
487 fn is_codewhale_owned_state_dir(dir: &Path) -> bool {
488 if dir.as_os_str().is_empty() {
489 return false;
490 }
491 let primary = codewhale_paths::codewhale_home().ok().flatten();
492 let legacy = (!codewhale_paths::codewhale_home_is_explicit())
493 .then(codewhale_paths::legacy_deepseek_home)
494 .flatten();
495 [primary, legacy].into_iter().flatten().any(|root| {
496 if root.as_os_str().is_empty() {
497 return false;
498 }
499 let Ok(physical_root) = std::fs::canonicalize(root) else {
500 return false;
501 };
502 let Ok(physical_dir) = std::fs::canonicalize(dir) else {
503 return false;
504 };
505 physical_dir.starts_with(physical_root)
506 })
507 }
508
509 /// Remove `.tmpXXXXXX` files this writer stranded in `dir` on an earlier run.
510 ///
511 /// `NamedTempFile` deletes itself on drop, so an ordinary failure — or an
512 /// ordinary exit — leaves nothing behind. A `SIGKILL` between `tempfile_in`
513 /// and `persist` cannot run a destructor, so the partial file survives, and
514 /// nothing ever collected it: five such strays (46 KB each, mode 0600) were
515 /// sitting in a real `~/.codewhale/` from a single day three weeks earlier.
516 /// They accumulate silently in the user's config directory forever.
517 ///
518 /// Deliberately conservative, because this deletes files under `$HOME`:
519 ///
520 /// - **Product directories only** — parent must be under `$CODEWHALE_HOME`
521 /// (or `~/.codewhale`) or the ambient `~/.deepseek` legacy root. User-chosen
522 /// destinations such as `/save <path>` keep the private permission policy
523 /// but are not swept (enforced at the call site).
524 /// - **Exact shape only** — `tempfile`'s default naming is the literal prefix
525 /// `.tmp` followed by exactly six alphanumerics and nothing else. A user file
526 /// called `.tmp`, `.tmpfile`, or `.tmp-backup` does not match.
527 /// - **Older than an hour** — so a concurrent write by another Codewhale
528 /// process is never raced. Same threshold and reasoning as
529 /// `shell_dispatcher::sweep_stale_temp_ps1`.
530 /// - **Best effort** — every failure is ignored; this must never turn a
531 /// successful write into an error.
532 fn sweep_stale_atomic_write_temps(dir: &Path) {
533 const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60 * 60);
534 let Ok(entries) = fs::read_dir(dir) else {
535 return;
536 };
537 for entry in entries.flatten() {
538 let name = entry.file_name();
539 let Some(name) = name.to_str() else {
540 continue;
541 };
542 if !is_stray_atomic_write_temp_name(name) {
543 continue;
544 }
545 // Only regular files; never follow or remove a symlink or directory.
546 let Ok(metadata) = entry.metadata() else {
547 continue;
548 };
549 if !metadata.is_file() {
550 continue;
551 }
552 let stale = metadata
553 .modified()
554 .ok()
555 .and_then(|modified| modified.elapsed().ok())
556 .is_some_and(|age| age > STALE_AFTER);
557 if stale {
558 let _ = fs::remove_file(entry.path());
559 }
560 }
561 }
562
563 /// `tempfile`'s default name: `.tmp` + exactly six ASCII alphanumerics.
564 fn is_stray_atomic_write_temp_name(name: &str) -> bool {
565 let Some(random) = name.strip_prefix(".tmp") else {
566 return false;
567 };
568 random.len() == 6 && random.chars().all(|c| c.is_ascii_alphanumeric())
569 }
570
571 /// Open or create a file for appending at `path`, optionally syncing after
572 /// every write. Use this for append-only logs like `audit.log`.
573 ///
574 /// The returned `BufWriter<fs::File>` wraps the append handle. Call
575 /// `.flush()` followed by `.get_ref().sync_all()` after each batch.
576 pub fn open_append(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
577 if let Some(parent) = path.parent() {
578 std::fs::create_dir_all(parent)?;
579 }
580 let file = std::fs::OpenOptions::new()
581 .create(true)
582 .append(true)
583 .open(path)?;
584 Ok(std::io::BufWriter::new(file))
585 }
586
587 /// Flush a `BufWriter` wrapping a `File`, then `fsync` the underlying file.
588 pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> std::io::Result<()> {
589 writer.flush()?;
590 writer.get_ref().sync_all()
591 }
592
593 /// Open a URL in the system's default browser.
594 ///
595 /// Dispatches to the platform-appropriate opener:
596 /// - macOS: `open`
597 /// - Linux / BSD: `xdg-open`
598 /// - Windows: `cmd /C start ""`
599 /// - Other: returns an error.
600 ///
601 /// This is the single entry point for URL opening — every call site in
602 /// the codebase should use this instead of hardcoding `Command::new("open")`,
603 /// `Command::new("xdg-open")`, or `Command::new("cmd")`.
604 pub fn open_url(url: &str) -> Result<()> {
605 let mut command = browser_open_command(url)?;
606 command
607 .stdout(std::process::Stdio::null())
608 .stderr(std::process::Stdio::null())
609 .spawn()
610 .map(|_| ())
611 .map_err(|e| anyhow::anyhow!("failed to launch browser command: {e}"))
612 }
613
614 fn browser_open_command(url: &str) -> Result<Command> {
615 if url.trim().is_empty() {
616 return Err(anyhow::anyhow!("browser URL cannot be empty"));
617 }
618
619 #[cfg(target_os = "macos")]
620 {
621 let mut command = Command::new("open");
622 command.arg(url);
623 Ok(command)
624 }
625
626 #[cfg(any(
627 all(target_os = "linux", not(target_env = "ohos")),
628 target_os = "netbsd",
629 target_os = "freebsd",
630 target_os = "openbsd",
631 target_os = "dragonfly"
632 ))]
633 {
634 let mut command = Command::new("xdg-open");
635 command.arg(url);
636 Ok(command)
637 }
638
639 #[cfg(target_os = "windows")]
640 {
641 let mut cmd = Command::new("cmd");
642 cmd.args(["/C", "start", "", url]);
643 Ok(cmd)
644 }
645
646 #[cfg(not(any(
647 target_os = "macos",
648 all(target_os = "linux", not(target_env = "ohos")),
649 target_os = "windows",
650 target_os = "netbsd",
651 target_os = "freebsd",
652 target_os = "openbsd",
653 target_os = "dragonfly"
654 )))]
655 Err(anyhow::anyhow!(
656 "browser opening is unsupported on this platform"
657 ))
658 }
659
660 /// Spawn a tokio task with panic supervision.
661 ///
662 /// Wraps the future in `AssertUnwindSafe` + `catch_unwind`. On panic:
663 /// 1. Logs the panic with the task name and caller location via `tracing::error!`.
664 /// 2. Writes a crash dump to `~/.codewhale/crashes/<timestamp>-<name>.log`.
665 ///
666 /// The returned `JoinHandle` resolves to `()` — the panic is caught and
667 /// handled internally so the parent process stays alive.
668 pub fn spawn_supervised<F>(
669 name: &'static str,
670 location: &'static std::panic::Location<'static>,
671 future: F,
672 ) -> tokio::task::JoinHandle<()>
673 where
674 F: std::future::Future<Output = ()> + Send + 'static,
675 {
676 tokio::spawn(async move {
677 use futures_util::FutureExt;
678 let result = std::panic::AssertUnwindSafe(future).catch_unwind().await;
679 if let Err(panic_info) = result {
680 let msg = panic_message(&*panic_info);
681 tracing::error!(
682 target: "panic",
683 "Task '{name}' panicked at {}: {msg}",
684 location,
685 );
686 // Write crash dump (best-effort)
687 let _ = write_panic_dump(name, location, &msg);
688 }
689 })
690 }
691
692 /// Extract a human-readable message from a caught panic payload (the `Err`
693 /// value of `catch_unwind`). Mirrors how the panic hook formats `&str` and
694 /// `String` payloads so crash dumps stay consistent across call sites.
695 #[must_use]
696 pub fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
697 if let Some(s) = panic.downcast_ref::<&str>() {
698 (*s).to_string()
699 } else if let Some(s) = panic.downcast_ref::<String>() {
700 s.clone()
701 } else {
702 "unknown panic".to_string()
703 }
704 }
705
706 /// Record a panic that was caught at a call site (via `catch_unwind`) rather
707 /// than by a task supervisor. Logs it on the `panic` target and writes a
708 /// best-effort crash dump to `~/.codewhale/crashes/`, so diagnostics land in
709 /// the same place `spawn_supervised` writes them even when the caller recovers
710 /// and keeps running.
711 #[track_caller]
712 pub fn record_caught_panic(name: &'static str, message: &str) {
713 let location = std::panic::Location::caller();
714 tracing::error!(target: "panic", "Task '{name}' panicked at {location}: {message}");
715 let _ = write_panic_dump(name, location, message);
716 // A caught panic is still a panic. The site is allowlist-reduced to
717 // `crates/…` or the literal `<dep>`, and `message` is deliberately not
718 // read: a slicing panic embeds the entire string being sliced. The exit
719 // class is left alone — the caller recovered, so this process is not
720 // ending here. A no-op unless this process was armed.
721 codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic {
722 site: codewhale_telemetry::reduce_panic_site(
723 location.file(),
724 location.line(),
725 location.column(),
726 ),
727 });
728 }
729
730 /// Write a panic dump file to `~/.codewhale/crashes/`.
731 ///
732 /// Creates the directory if needed and writes a timestamped log
733 /// with the task name, caller location, and panic message.
734 /// Best-effort — failures are silently ignored.
735 fn write_panic_dump(
736 name: &str,
737 location: &std::panic::Location<'_>,
738 message: &str,
739 ) -> std::io::Result<()> {
740 let home = crate::config::effective_home_dir().ok_or_else(|| {
741 std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found")
742 })?;
743 // Prefer .codewhale, fall back to .deepseek
744 let crash_dir = home.join(".codewhale").join("crashes");
745 if !crash_dir.exists() {
746 // Try legacy path for reading, but prefer new for writing
747 let _ = std::fs::create_dir_all(&crash_dir);
748 }
749 let crash_dir = if crash_dir.exists() {
750 crash_dir
751 } else {
752 home.join(".deepseek").join("crashes")
753 };
754 write_panic_dump_to(&crash_dir, name, location, message)
755 }
756
757 fn write_panic_dump_to(
758 crash_dir: &Path,
759 name: &str,
760 location: &std::panic::Location<'_>,
761 message: &str,
762 ) -> std::io::Result<()> {
763 use chrono::Utc;
764 std::fs::create_dir_all(crash_dir)?;
765 let timestamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
766 let filename = format!("{timestamp}-{name}.log");
767 let path = crash_dir.join(&filename);
768 let contents =
769 format!("Task: {name}\nLocation: {location}\nTimestamp: {timestamp}\nPanic: {message}\n");
770 std::fs::write(&path, contents)?;
771 Ok(())
772 }
773
774 /// Fire-and-forget `spawn_blocking` with panic dump protection.
775 ///
776 /// In contrast to `spawn_supervised` (which wraps `tokio::spawn` for async
777 /// tasks), this helper wraps `tokio::task::spawn_blocking`. Use it when a
778 /// CPU-bound or blocking-I/O task must run off the async runtime and its
779 /// completion is *not* awaited — for example a post-turn disk snapshot or a
780 /// file-tree build polled later via a shared data structure. If the closure
781 /// panics, a crash dump is written to `~/.codewhale/crashes/` and the panic
782 /// is logged at ERROR level rather than being silently swallowed.
783 #[track_caller]
784 pub fn spawn_blocking_supervised<F>(name: &'static str, f: F) -> tokio::task::JoinHandle<()>
785 where
786 F: FnOnce() + Send + 'static,
787 {
788 let location = std::panic::Location::caller();
789 tokio::task::spawn_blocking(move || {
790 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
791 if let Err(panic_info) = result {
792 let msg = panic_message(&*panic_info);
793 tracing::error!(
794 target: "panic",
795 "Blocking task '{name}' panicked at {location}: {msg}",
796 );
797 let _ = write_panic_dump(name, location, &msg);
798 }
799 })
800 }
801
802 /// Truncate a string to a maximum length, adding an ellipsis if truncated.
803 ///
804 /// Uses char boundaries to avoid panicking on multi-byte UTF-8 characters.
805 #[must_use]
806 pub fn truncate_with_ellipsis(s: &str, max_len: usize, ellipsis: &str) -> String {
807 if s.len() <= max_len {
808 return s.to_string();
809 }
810 let budget = max_len.saturating_sub(ellipsis.len());
811 // Find the last char boundary that fits within the byte budget.
812 let safe_end = s
813 .char_indices()
814 .map(|(i, _)| i)
815 .take_while(|&i| i <= budget)
816 .last()
817 .unwrap_or(0);
818 format!("{}{}", &s[..safe_end], ellipsis)
819 }
820
821 /// Percent-encode a string for use in URL query parameters.
822 ///
823 /// Encodes all characters except unreserved characters (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`).
824 /// Spaces are encoded as `+`.
825 #[must_use]
826 pub fn url_encode(input: &str) -> String {
827 let mut encoded = String::new();
828 for ch in input.bytes() {
829 match ch {
830 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
831 encoded.push(ch as char)
832 }
833 b' ' => encoded.push('+'),
834 _ => encoded.push_str(&format!("%{ch:02X}")),
835 }
836 }
837 encoded
838 }
839
840 /// Render a path for **user-facing display** with the home directory
841 /// contracted to `~`. Use this in the TUI, doctor/setup stdout, and any
842 /// other place a viewer might see the output (screenshot, video,
843 /// pasted-into-issue help). On macOS/Linux the absolute path
844 /// `/Users/<name>/...` or `/home/<name>/...` reveals the OS account name,
845 /// which is often the same as a public handle — undesirable for users
846 /// who share their terminal.
847 ///
848 /// **Do not use** this for paths that get persisted (sessions, audit log)
849 /// or sent to the LLM provider — those want full fidelity so they
850 /// resolve correctly across processes.
851 #[must_use]
852 pub fn display_path(path: &Path) -> String {
853 display_path_with_home(path, crate::config::effective_home_dir().as_deref())
854 }
855
856 /// Like [`display_path`] but takes an explicit home directory instead of
857 /// reading `$HOME` / `crate::config::effective_home_dir()`. Used in tests and anywhere the
858 /// caller already has the home path available.
859 ///
860 /// The home-relative suffix is rejoined with the platform separator
861 /// (`\` on Windows, `/` elsewhere) by walking the path's components, so
862 /// inputs that carried foreign separators don't leak through.
863 #[must_use]
864 pub fn display_path_with_home(path: &Path, home: Option<&Path>) -> String {
865 let Some(home) = home else {
866 return path.display().to_string();
867 };
868 if let Ok(rest) = path.strip_prefix(home) {
869 if rest.as_os_str().is_empty() {
870 return "~".to_string();
871 }
872 let sep = std::path::MAIN_SEPARATOR_STR;
873 let mut out = String::from("~");
874 for component in rest.components() {
875 out.push_str(sep);
876 out.push_str(&component.as_os_str().to_string_lossy());
877 }
878 return out;
879 }
880 path.display().to_string()
881 }
882
883 /// Estimate the total character count across message content blocks.
884 #[must_use]
885 pub fn estimate_message_chars(messages: &[Message]) -> usize {
886 let mut total = 0;
887 for msg in messages {
888 for block in &msg.content {
889 match block {
890 ContentBlock::Text { text, .. } => total += text.len(),
891 ContentBlock::Thinking { thinking, .. } => total += thinking.len(),
892 ContentBlock::ToolUse { input, .. } => {
893 let mut cw = CountingWriter::new();
894 let _ = serde_json::to_writer(&mut cw, input);
895 total += cw.count();
896 }
897 ContentBlock::ToolResult { content, .. } => total += content.len(),
898 ContentBlock::ServerToolUse { .. }
899 | ContentBlock::ToolSearchToolResult { .. }
900 | ContentBlock::CodeExecutionToolResult { .. }
901 | ContentBlock::ImageUrl { .. } => {}
902 }
903 }
904 }
905 total
906 }
907
908 // Tests use `display_path_with_home` so they never mutate the global `HOME`
909 // env var. Mutating `HOME` via `std::env::set_var` is not thread-safe; Cargo
910 // runs tests in parallel by default and CI runners are multi-core, so any test
911 // that stomps `HOME` will race with tests that *read* it. Using the injected
912 // helper avoids the race entirely and makes the tests portable to Windows
913 // without additional platform scaffolding.
914 #[cfg(test)]
915 mod tests {
916 use super::{display_path_with_home, redacted_identifier_for_log};
917 use std::path::PathBuf;
918
919 fn home(s: &str) -> Option<PathBuf> {
920 Some(PathBuf::from(s))
921 }
922
923 #[test]
924 fn redacted_identifier_for_log_hides_value_and_stays_stable() {
925 let identifier = "session-secret-1234567890";
926 let redacted = redacted_identifier_for_log(identifier);
927
928 assert!(redacted.starts_with("<redacted:"));
929 assert!(redacted.ends_with('>'));
930 assert!(!redacted.contains(identifier));
931 assert_eq!(redacted, redacted_identifier_for_log(identifier));
932 assert_ne!(redacted, redacted_identifier_for_log("another-session"));
933 }
934
935 #[test]
936 fn redacted_identifier_for_log_marks_empty_values() {
937 assert_eq!(redacted_identifier_for_log(""), "<redacted:empty>");
938 }
939
940 #[test]
941 fn display_path_contracts_home_prefix() {
942 let h = home("/Users/alice");
943 assert_eq!(
944 display_path_with_home(&PathBuf::from("/Users/alice/projects/foo"), h.as_deref()),
945 format!(
946 "~{}projects{}foo",
947 std::path::MAIN_SEPARATOR,
948 std::path::MAIN_SEPARATOR
949 ),
950 );
951 }
952
953 #[test]
954 fn display_path_returns_bare_tilde_for_home_itself() {
955 let h = home("/Users/alice");
956 assert_eq!(
957 display_path_with_home(&PathBuf::from("/Users/alice"), h.as_deref()),
958 "~"
959 );
960 }
961
962 #[test]
963 fn display_path_leaves_unrelated_paths_alone() {
964 let h = home("/Users/alice");
965 // Different user — must not get rewritten or share the tilde.
966 assert_eq!(
967 display_path_with_home(&PathBuf::from("/Users/bob/Code"), h.as_deref()),
968 "/Users/bob/Code".to_string()
969 );
970 // System path must stay absolute.
971 assert_eq!(
972 display_path_with_home(&PathBuf::from("/etc/hosts"), h.as_deref()),
973 "/etc/hosts"
974 );
975 }
976
977 #[test]
978 fn display_path_does_not_match_username_prefix() {
979 // Regression guard: a directory named like the user's home
980 // *prefix* but not under it must not get rewritten.
981 let h = home("/Users/alice");
982 assert_eq!(
983 display_path_with_home(&PathBuf::from("/Users/alice2/work"), h.as_deref()),
984 "/Users/alice2/work"
985 );
986 }
987
988 #[test]
989 fn display_path_with_no_home_returns_full_path() {
990 assert_eq!(
991 display_path_with_home(&PathBuf::from("/some/path"), None),
992 "/some/path"
993 );
994 }
995 }
996
997 #[cfg(test)]
998 mod atomic_write_tests {
999 use super::*;
1000 use std::fs;
1001 use tempfile::tempdir;
1002
1003 #[test]
1004 fn write_atomic_writes_content() {
1005 let tmp = tempdir().expect("tempdir");
1006 let path = tmp.path().join("test.json");
1007 let content = b"hello atomic world";
1008
1009 write_atomic(&path, content).expect("write_atomic");
1010 assert!(path.exists());
1011 let read = fs::read_to_string(&path).expect("read");
1012 assert_eq!(read.as_bytes(), content);
1013 }
1014
1015 #[test]
1016 fn write_atomic_replaces_existing_file() {
1017 let tmp = tempdir().expect("tempdir");
1018 let path = tmp.path().join("existing.json");
1019 fs::write(&path, b"old content").expect("write old");
1020 write_atomic(&path, b"new content").expect("write_atomic");
1021 let read = fs::read_to_string(&path).expect("read");
1022 assert_eq!(read, "new content");
1023 }
1024
1025 #[cfg(windows)]
1026 #[test]
1027 fn write_atomic_retries_windows_replace_contention() {
1028 use std::os::windows::fs::OpenOptionsExt;
1029
1030 let tmp = tempdir().expect("tempdir");
1031 let path = tmp.path().join("contended.json");
1032 fs::write(&path, b"old content").expect("write old");
1033
1034 // FILE_SHARE_READ | FILE_SHARE_WRITE deliberately omits
1035 // FILE_SHARE_DELETE, reproducing the short-lived handle contention
1036 // that makes MoveFileExW report access denied during replacement.
1037 let held = fs::OpenOptions::new()
1038 .read(true)
1039 .share_mode(0x1 | 0x2)
1040 .open(&path)
1041 .expect("hold destination without delete sharing");
1042 let release = std::thread::spawn(move || {
1043 std::thread::sleep(std::time::Duration::from_millis(50));
1044 drop(held);
1045 });
1046
1047 write_atomic(&path, b"new content").expect("retry contended atomic replacement");
1048 release.join().expect("release destination handle");
1049 assert_eq!(fs::read(&path).expect("read replacement"), b"new content");
1050 }
1051
1052 #[test]
1053 fn write_atomic_no_temp_left_behind_on_success() {
1054 let tmp = tempdir().expect("tempdir");
1055 let path = tmp.path().join("clean.json");
1056 write_atomic(&path, b"clean").expect("write_atomic");
1057 // List files in dir — there should be no .tmp files left
1058 let entries: Vec<_> = fs::read_dir(tmp.path())
1059 .expect("read_dir")
1060 .filter_map(|e| e.ok())
1061 .collect();
1062 let tmp_files: Vec<_> = entries
1063 .iter()
1064 .filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with('.')))
1065 .collect();
1066 assert!(
1067 tmp_files.is_empty(),
1068 "temp files left behind: {tmp_files:?}"
1069 );
1070 }
1071
1072 #[cfg(any(unix, windows))]
1073 fn assert_workspace_hard_link_is_refused() {
1074 let dir = tempdir().expect("tempdir");
1075 let workspace = dir.path().join("workspace");
1076 let outside = dir.path().join("outside");
1077 fs::create_dir_all(&workspace).expect("create workspace");
1078 fs::create_dir_all(&outside).expect("create outside directory");
1079 let outside = outside.join("outside.txt");
1080 fs::write(&outside, b"outside").expect("outside state");
1081 let linked = workspace.join("linked.txt");
1082 fs::hard_link(&outside, &linked).expect("hard link");
1083
1084 let err = write_atomic_workspace(&linked, b"new").expect_err("must refuse");
1085 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1086 assert!(
1087 err.to_string().contains("hard links"),
1088 "the refusal must name the hard-link reason: {err}"
1089 );
1090 assert_eq!(
1091 fs::read_to_string(&outside).expect("outside read"),
1092 "outside",
1093 "the outside file must stay untouched"
1094 );
1095 assert_eq!(
1096 fs::read_to_string(&linked).expect("linked read"),
1097 "outside",
1098 "the workspace entry must stay untouched too"
1099 );
1100 // Breaking the link re-enables normal writes.
1101 fs::remove_file(&linked).expect("break link");
1102 write_atomic_workspace(&linked, b"fresh").expect("single-link write");
1103 assert_eq!(fs::read_to_string(&linked).expect("fresh read"), "fresh");
1104 assert_eq!(
1105 fs::read_to_string(&outside).expect("outside read"),
1106 "outside"
1107 );
1108 }
1109
1110 #[cfg(unix)]
1111 #[test]
1112 fn write_atomic_workspace_refuses_a_hard_linked_target() {
1113 assert_workspace_hard_link_is_refused();
1114 }
1115
1116 #[cfg(windows)]
1117 #[test]
1118 fn windows_write_atomic_workspace_refuses_a_hard_linked_target() {
1119 assert_workspace_hard_link_is_refused();
1120 }
1121
1122 #[cfg(windows)]
1123 #[test]
1124 fn windows_hard_link_count_detects_multiple_links() {
1125 let dir = tempdir().expect("tempdir");
1126 let first = dir.path().join("first.txt");
1127 let second = dir.path().join("second.txt");
1128 fs::write(&first, b"linked").expect("write fixture");
1129 fs::hard_link(&first, &second).expect("hard link");
1130
1131 assert_eq!(hard_link_count(&second), Some(2));
1132 }
1133
1134 #[cfg(unix)]
1135 #[test]
1136 fn write_atomic_workspace_new_file_matches_standard_creation_mode() {
1137 use std::os::unix::fs::PermissionsExt;
1138
1139 let dir = tempdir().expect("tempdir");
1140 let control = dir.path().join("control.txt");
1141 let actual = dir.path().join("actual.txt");
1142
1143 fs::write(&control, b"control").expect("write control");
1144 write_atomic_workspace(&actual, b"actual").expect("atomic workspace write");
1145
1146 let control_mode = fs::metadata(&control)
1147 .expect("control metadata")
1148 .permissions()
1149 .mode()
1150 & 0o777;
1151 let actual_mode = fs::metadata(&actual)
1152 .expect("actual metadata")
1153 .permissions()
1154 .mode()
1155 & 0o777;
1156
1157 assert_eq!(actual_mode, control_mode);
1158 assert_eq!(fs::read(&actual).expect("read"), b"actual");
1159 }
1160
1161 #[cfg(unix)]
1162 #[test]
1163 fn write_atomic_workspace_preserves_existing_mode() {
1164 use std::os::unix::fs::PermissionsExt;
1165
1166 let dir = tempdir().expect("tempdir");
1167 let path = dir.path().join("shared.txt");
1168 fs::write(&path, b"before").expect("initial write");
1169 fs::set_permissions(&path, fs::Permissions::from_mode(0o664))
1170 .expect("set shared permissions");
1171
1172 write_atomic_workspace(&path, b"after").expect("atomic workspace write");
1173
1174 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
1175 assert_eq!(mode, 0o664);
1176 assert_eq!(fs::read(&path).expect("read"), b"after");
1177 }
1178
1179 #[cfg(unix)]
1180 #[test]
1181 fn write_atomic_workspace_preserves_executable_bits() {
1182 use std::os::unix::fs::PermissionsExt;
1183
1184 let dir = tempdir().expect("tempdir");
1185 let path = dir.path().join("script.sh");
1186 fs::write(&path, b"#!/bin/sh\nexit 0\n").expect("initial write");
1187 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
1188 .expect("set executable permissions");
1189
1190 write_atomic_workspace(&path, b"#!/bin/sh\nexit 1\n").expect("atomic workspace write");
1191
1192 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
1193 assert_eq!(mode, 0o755);
1194 assert_eq!(fs::read(&path).expect("read"), b"#!/bin/sh\nexit 1\n");
1195 }
1196
1197 #[cfg(unix)]
1198 #[test]
1199 fn write_atomic_workspace_does_not_restore_special_bits() {
1200 use std::os::unix::fs::PermissionsExt;
1201
1202 let dir = tempdir().expect("tempdir");
1203 let path = dir.path().join("special.sh");
1204 fs::write(&path, b"#!/bin/sh\n").expect("initial write");
1205 // Request sticky + setgid + rwxr-xr-x. Filesystems may clear some
1206 // special bits; we only assert that after rewrite we never keep
1207 // bits outside the ordinary 0o777 mask.
1208 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o6755));
1209 let before = fs::metadata(&path)
1210 .expect("metadata before")
1211 .permissions()
1212 .mode();
1213 let expected_ordinary = before & 0o777;
1214
1215 write_atomic_workspace(&path, b"#!/bin/sh\necho rewritten\n")
1216 .expect("atomic workspace write");
1217
1218 let after = fs::metadata(&path)
1219 .expect("metadata after")
1220 .permissions()
1221 .mode();
1222 assert_eq!(after & 0o777, expected_ordinary);
1223 // `PermissionsExt::mode()` also contains the regular-file type bit on
1224 // macOS/BSD. Check only the Unix special permission bits rather than
1225 // treating every non-rwx bit as a restored permission.
1226 assert_eq!(after & 0o7000, 0, "special bits must not be restored");
1227 }
1228
1229 #[cfg(unix)]
1230 #[test]
1231 fn write_atomic_workspace_replaces_symlink_without_following_target() {
1232 use std::os::unix::fs::{PermissionsExt, symlink};
1233
1234 let dir = tempdir().expect("tempdir");
1235 let target = dir.path().join("target.txt");
1236 let link = dir.path().join("link.txt");
1237 fs::write(&target, b"target-body").expect("write target");
1238 fs::set_permissions(&target, fs::Permissions::from_mode(0o600))
1239 .expect("lock down target mode");
1240 symlink(&target, &link).expect("create symlink");
1241
1242 write_atomic_workspace(&link, b"replaced-link")
1243 .expect("workspace write must replace symlink directory entry");
1244
1245 let link_meta = fs::symlink_metadata(&link).expect("link metadata");
1246 assert!(
1247 link_meta.file_type().is_file() && !link_meta.file_type().is_symlink(),
1248 "rename should replace the symlink with a regular file"
1249 );
1250 assert_eq!(fs::read(&link).expect("read link path"), b"replaced-link");
1251 // Target inode must remain untouched (old private write_atomic semantics).
1252 assert_eq!(fs::read(&target).expect("read target"), b"target-body");
1253 assert_eq!(
1254 fs::metadata(&target)
1255 .expect("target metadata")
1256 .permissions()
1257 .mode()
1258 & 0o777,
1259 0o600
1260 );
1261 }
1262
1263 #[cfg(unix)]
1264 #[test]
1265 fn write_atomic_workspace_replaces_self_referential_symlink_without_following() {
1266 use std::os::unix::fs::symlink;
1267
1268 let dir = tempdir().expect("tempdir");
1269 let link = dir.path().join("self-link.txt");
1270 symlink(&link, &link).expect("create self-referential symlink");
1271
1272 assert!(
1273 fs::symlink_metadata(&link)
1274 .expect("lstat self-referential symlink")
1275 .file_type()
1276 .is_symlink()
1277 );
1278 let follow_error = fs::metadata(&link).expect_err("following the symlink must fail");
1279 assert_ne!(
1280 follow_error.kind(),
1281 std::io::ErrorKind::NotFound,
1282 "the fixture must catch a metadata-following regression"
1283 );
1284
1285 let result = write_atomic_workspace(&link, b"new-content");
1286 result.expect("workspace write must not follow a self-referential symlink");
1287 let link_meta = fs::symlink_metadata(&link).expect("link metadata");
1288 assert!(link_meta.file_type().is_file() && !link_meta.file_type().is_symlink());
1289 assert_eq!(fs::read(&link).expect("read"), b"new-content");
1290 }
1291
1292 #[cfg(unix)]
1293 #[test]
1294 fn write_atomic_private_new_file_does_not_gain_group_or_other_access() {
1295 use std::os::unix::fs::PermissionsExt;
1296
1297 let dir = tempdir().expect("tempdir");
1298 let path = dir.path().join("private.json");
1299
1300 write_atomic(&path, b"{}").expect("private atomic write");
1301
1302 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
1303 assert_eq!(mode & 0o077, 0);
1304 }
1305
1306 #[test]
1307 fn write_atomic_workspace_rewrites_a_single_link_file() {
1308 let tmp = tempdir().expect("tempdir");
1309 let path = tmp.path().join("workspace.txt");
1310 fs::write(&path, b"before").expect("write initial content");
1311 write_atomic_workspace(&path, b"workspace").expect("write_atomic_workspace");
1312 assert_eq!(fs::read(&path).expect("read"), b"workspace");
1313 }
1314
1315 #[test]
1316 fn flush_and_sync_writes_and_syncs() {
1317 let tmp = tempdir().expect("tempdir");
1318 let path = tmp.path().join("append.log");
1319 {
1320 let mut writer = open_append(&path).expect("open_append");
1321 writeln!(writer, "line 1").expect("write");
1322 flush_and_sync(&mut writer).expect("flush_and_sync");
1323 writeln!(writer, "line 2").expect("write");
1324 flush_and_sync(&mut writer).expect("flush_and_sync");
1325 }
1326 let content = fs::read_to_string(&path).expect("read");
1327 assert_eq!(content, "line 1\nline 2\n");
1328 }
1329
1330 // === stray atomic-write temp files ===
1331
1332 /// Backdate a file's mtime past the sweeper's one-hour threshold, using
1333 /// std rather than pulling in a dev-dependency just to age a fixture.
1334 fn age_past_the_threshold(path: &std::path::Path) {
1335 let two_hours_ago =
1336 std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
1337 let file = std::fs::File::options()
1338 .write(true)
1339 .open(path)
1340 .expect("open fixture");
1341 file.set_times(std::fs::FileTimes::new().set_modified(two_hours_ago))
1342 .expect("age the fixture");
1343 }
1344
1345 #[test]
1346 fn stray_temp_names_match_only_tempfiles_default_shape() {
1347 // What `tempfile` actually produces.
1348 assert!(super::is_stray_atomic_write_temp_name(".tmp0dqfST"));
1349 assert!(super::is_stray_atomic_write_temp_name(".tmpBcX9dY"));
1350 assert!(super::is_stray_atomic_write_temp_name(".tmpABC123"));
1351
1352 // User files that must never be swept.
1353 for safe in [
1354 ".tmp",
1355 ".tmpfile",
1356 ".tmp-backup",
1357 ".tmp12345", // five
1358 ".tmp1234567", // seven
1359 ".tmpABC12_", // underscore is not alphanumeric
1360 "tmpABC123", // no leading dot
1361 ".temp123456",
1362 "config.toml",
1363 ] {
1364 assert!(
1365 !super::is_stray_atomic_write_temp_name(safe),
1366 "{safe} must not be treated as ours to delete"
1367 );
1368 }
1369 }
1370
1371 #[test]
1372 fn sweeping_removes_only_old_strays_and_leaves_everything_else() {
1373 let dir = tempfile::TempDir::new().expect("tempdir");
1374 let old_stray = dir.path().join(".tmpAAAAAA");
1375 let fresh_stray = dir.path().join(".tmpBBBBBB");
1376 let user_file = dir.path().join(".tmp-please-keep");
1377 let real_file = dir.path().join("config.toml");
1378 for path in [&old_stray, &fresh_stray, &user_file, &real_file] {
1379 std::fs::write(path, b"x").expect("write fixture");
1380 }
1381
1382 age_past_the_threshold(&old_stray);
1383
1384 super::sweep_stale_atomic_write_temps(dir.path());
1385
1386 assert!(
1387 !old_stray.exists(),
1388 "an hour-old stray of ours is collected"
1389 );
1390 assert!(
1391 fresh_stray.exists(),
1392 "a fresh stray may belong to a concurrent write and must be left alone"
1393 );
1394 assert!(user_file.exists(), "a user file must never be swept");
1395 assert!(real_file.exists());
1396 }
1397
1398 /// Seal HOME / CODEWHALE_HOME to `tmp` so sweep policy is deterministic
1399 /// and never inspects the developer's real `~/.codewhale`.
1400 fn seal_product_home(
1401 tmp: &std::path::Path,
1402 ) -> (std::path::PathBuf, Vec<crate::test_support::EnvVarGuard>) {
1403 use crate::test_support::EnvVarGuard;
1404
1405 let product = tmp.join("product-home");
1406 std::fs::create_dir_all(&product).expect("create product home");
1407 let guards = vec![
1408 EnvVarGuard::set("HOME", tmp),
1409 EnvVarGuard::set("USERPROFILE", tmp),
1410 EnvVarGuard::set("CODEWHALE_HOME", &product),
1411 EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
1412 EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
1413 EnvVarGuard::remove("DEEPSEEK_HOME"),
1414 ];
1415 (product, guards)
1416 }
1417
1418 #[test]
1419 fn a_private_atomic_write_in_a_product_dir_collects_strays() {
1420 let _lock = crate::test_support::lock_test_env();
1421 let tmp = tempfile::TempDir::new().expect("tempdir");
1422 let (product, _guards) = seal_product_home(tmp.path());
1423
1424 assert!(
1425 super::is_codewhale_owned_state_dir(&product),
1426 "sealed CODEWHALE_HOME must count as a product dir"
1427 );
1428
1429 let stray = product.join(".tmpCCCCCC");
1430 std::fs::write(&stray, b"stranded by a SIGKILL").expect("write stray");
1431 age_past_the_threshold(&stray);
1432
1433 let target = product.join("state.json");
1434 super::write_atomic(&target, b"{\"ok\":true}").expect("atomic write");
1435
1436 assert_eq!(std::fs::read(&target).expect("read back"), b"{\"ok\":true}");
1437 assert!(!stray.exists(), "the write reclaimed the earlier stray");
1438 }
1439
1440 #[test]
1441 fn a_private_atomic_write_to_a_user_chosen_dest_does_not_sweep() {
1442 let _lock = crate::test_support::lock_test_env();
1443 let tmp = tempfile::TempDir::new().expect("tempdir");
1444 let (_product, _guards) = seal_product_home(tmp.path());
1445 let user_dir = tmp.path().join("user-chosen");
1446 std::fs::create_dir_all(&user_dir).expect("create user dest");
1447
1448 assert!(
1449 !super::is_codewhale_owned_state_dir(&user_dir),
1450 "user-chosen dest must not count as a product dir: {}",
1451 user_dir.display()
1452 );
1453
1454 let stray = user_dir.join(".tmpDDDDDD");
1455 std::fs::write(&stray, b"user or concurrent tempfile").expect("write stray");
1456 age_past_the_threshold(&stray);
1457
1458 let target = user_dir.join("session.json");
1459 super::write_atomic(&target, b"{\"ok\":true}").expect("atomic write");
1460
1461 assert_eq!(std::fs::read(&target).expect("read back"), b"{\"ok\":true}");
1462 assert!(
1463 stray.exists(),
1464 "/save <path> and other user-chosen dests must not sweep the parent"
1465 );
1466 }
1467
1468 #[test]
1469 fn atomic_write_product_dir_detection_matches_codewhale_home_not_siblings() {
1470 let _lock = crate::test_support::lock_test_env();
1471 let tmp = tempfile::TempDir::new().expect("tempdir");
1472 let (product, explicit_guards) = seal_product_home(tmp.path());
1473 let sessions = product.join("sessions");
1474 std::fs::create_dir_all(&sessions).expect("create sessions");
1475 let sibling = tmp.path().join("product-home-extra");
1476 std::fs::create_dir_all(&sibling).expect("create sibling");
1477
1478 assert!(super::is_codewhale_owned_state_dir(&product));
1479 assert!(super::is_codewhale_owned_state_dir(&sessions));
1480 assert!(!super::is_codewhale_owned_state_dir(&sibling));
1481 assert!(!super::is_codewhale_owned_state_dir(tmp.path()));
1482 drop(explicit_guards);
1483
1484 use crate::test_support::EnvVarGuard;
1485 let _home = EnvVarGuard::set("HOME", tmp.path());
1486 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1487 let _no_explicit = EnvVarGuard::remove("CODEWHALE_HOME");
1488 let _no_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
1489 let _no_legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
1490 let _no_legacy_home = EnvVarGuard::remove("DEEPSEEK_HOME");
1491 let primary_sessions = tmp.path().join(".codewhale").join("sessions");
1492 let legacy_home = tmp.path().join(".deepseek");
1493 std::fs::create_dir_all(&primary_sessions).expect("create primary sessions");
1494 std::fs::create_dir_all(&legacy_home).expect("create legacy home");
1495
1496 assert!(super::is_codewhale_owned_state_dir(&primary_sessions));
1497 assert!(super::is_codewhale_owned_state_dir(&legacy_home));
1498 assert!(!super::is_codewhale_owned_state_dir(
1499 &tmp.path().join("user-chosen")
1500 ));
1501 }
1502
1503 #[cfg(unix)]
1504 #[test]
1505 fn symlinked_product_subdir_cannot_sweep_an_external_directory() {
1506 use std::os::unix::fs::symlink;
1507
1508 let _lock = crate::test_support::lock_test_env();
1509 let tmp = tempfile::TempDir::new().expect("tempdir");
1510 let (product, _guards) = seal_product_home(tmp.path());
1511 let external = tmp.path().join("external");
1512 std::fs::create_dir_all(&external).expect("create external dir");
1513 let linked = product.join("exports");
1514 symlink(&external, &linked).expect("link product subdir outside");
1515
1516 let stray = external.join(".tmpEEEEEE");
1517 std::fs::write(&stray, b"external tempfile").expect("write external fixture");
1518 age_past_the_threshold(&stray);
1519
1520 assert!(
1521 !super::is_codewhale_owned_state_dir(&linked),
1522 "physical containment must reject a nested symlink escape"
1523 );
1524 super::write_atomic(&linked.join("state.json"), b"{\"ok\":true}")
1525 .expect("atomic write through link remains non-sweeping");
1526
1527 assert!(
1528 stray.exists(),
1529 "external temp-shaped files must not be swept"
1530 );
1531 }
1532 }
1533
1534 #[cfg(test)]
1535 mod spawn_supervised_tests {
1536 use super::*;
1537 use std::sync::Arc;
1538 use std::sync::atomic::{AtomicBool, Ordering};
1539
1540 /// A spawned task that panics does not propagate the panic to the
1541 /// parent task — `spawn_supervised` catches it. Verified in isolation
1542 /// from the on-disk crash-dump path so the test is portable across
1543 /// macOS / Linux / Windows (where `crate::config::effective_home_dir()` reads
1544 /// `USERPROFILE`, not `HOME`, so env-mutation tricks don't redirect
1545 /// the dump on Windows).
1546 #[tokio::test]
1547 async fn panicking_task_does_not_propagate_to_parent() {
1548 let parent_alive = Arc::new(AtomicBool::new(false));
1549 let parent_alive_clone = parent_alive.clone();
1550
1551 let handle = spawn_supervised(
1552 "panic-test-fixture",
1553 std::panic::Location::caller(),
1554 async move {
1555 parent_alive_clone.store(true, Ordering::SeqCst);
1556 panic!("deliberate panic for catch-unwind test");
1557 },
1558 );
1559
1560 let result = handle.await;
1561 assert!(
1562 result.is_ok(),
1563 "spawn_supervised must convert panic to a normal completion"
1564 );
1565 assert!(
1566 parent_alive.load(Ordering::SeqCst),
1567 "fixture task must have run before panicking"
1568 );
1569 }
1570
1571 #[tokio::test]
1572 async fn panicking_blocking_task_does_not_propagate_to_parent() {
1573 let parent_alive = Arc::new(AtomicBool::new(false));
1574 let parent_alive_clone = parent_alive.clone();
1575
1576 let handle = spawn_blocking_supervised("blocking-panic-test-fixture", move || {
1577 parent_alive_clone.store(true, Ordering::SeqCst);
1578 panic!("deliberate panic for spawn_blocking catch-unwind test");
1579 });
1580
1581 let result = handle.await;
1582 assert!(
1583 result.is_ok(),
1584 "spawn_blocking_supervised must convert panic to a normal completion"
1585 );
1586 assert!(
1587 parent_alive.load(Ordering::SeqCst),
1588 "fixture blocking task must have run before panicking"
1589 );
1590 }
1591
1592 /// `write_panic_dump_to` writes a properly-formatted crash log into
1593 /// the supplied directory. Tested separately from `spawn_supervised`
1594 /// because env-mutation redirection of `crate::config::effective_home_dir()` doesn't
1595 /// work on Windows.
1596 #[test]
1597 fn write_panic_dump_writes_named_log() {
1598 let tmp = tempfile::tempdir().expect("tempdir");
1599 let crash_dir = tmp.path().join("crashes");
1600 let location = std::panic::Location::caller();
1601 write_panic_dump_to(&crash_dir, "panic-fixture", location, "boom").expect("write dump");
1602
1603 let entries: Vec<_> = std::fs::read_dir(&crash_dir)
1604 .expect("crashes dir exists")
1605 .flatten()
1606 .collect();
1607 assert_eq!(entries.len(), 1, "exactly one crash dump expected");
1608 let dump = std::fs::read_to_string(entries[0].path()).expect("read dump");
1609 assert!(
1610 dump.contains("panic-fixture"),
1611 "dump must include the task name; got: {dump}"
1612 );
1613 assert!(
1614 dump.contains("boom"),
1615 "dump must include the panic message; got: {dump}"
1616 );
1617 }
1618 }
1619
1620 #[cfg(test)]
1621 mod project_mapping_tests {
1622 use super::{project_tree, summarize_project};
1623 use std::fs;
1624 use tempfile::tempdir;
1625
1626 #[test]
1627 fn project_tree_sorts_siblings_alphabetically() {
1628 // Cross-platform readdir doesn't guarantee alphabetical order — on
1629 // ext4 with htree it's hash order, on APFS it's roughly insertion
1630 // order, on ZFS it's storage-class dependent. The system prompt
1631 // embeds this string in the cached prefix when a workspace has no
1632 // AGENTS.md / CLAUDE.md, so the function has to be byte-stable
1633 // across runs regardless of host filesystem.
1634 let tmp = tempdir().expect("tempdir");
1635 let root = tmp.path();
1636 // Create files in a deliberately scrambled order to make the
1637 // hosting filesystem's pre-sort (if any) less likely to mask a
1638 // missing sort in our code.
1639 fs::write(root.join("zebra.txt"), "z").expect("write zebra");
1640 fs::write(root.join("apple.txt"), "a").expect("write apple");
1641 fs::write(root.join("mango.txt"), "m").expect("write mango");
1642
1643 let tree = project_tree(root, 1, false);
1644 let lines: Vec<&str> = tree.lines().collect();
1645 let apple_pos = lines
1646 .iter()
1647 .position(|l| l.contains("apple.txt"))
1648 .expect("apple line");
1649 let mango_pos = lines
1650 .iter()
1651 .position(|l| l.contains("mango.txt"))
1652 .expect("mango line");
1653 let zebra_pos = lines
1654 .iter()
1655 .position(|l| l.contains("zebra.txt"))
1656 .expect("zebra line");
1657
1658 assert!(apple_pos < mango_pos);
1659 assert!(mango_pos < zebra_pos);
1660 }
1661
1662 #[test]
1663 fn project_tree_keeps_directory_before_its_children() {
1664 // Sorting siblings by full path is enough to preserve tree shape:
1665 // `"src" < "src/lib.rs"` because the shorter string compares less.
1666 let tmp = tempdir().expect("tempdir");
1667 let root = tmp.path();
1668 let src = root.join("src");
1669 fs::create_dir_all(&src).expect("mkdir src");
1670 fs::write(src.join("lib.rs"), "lib").expect("write lib");
1671 fs::write(src.join("main.rs"), "main").expect("write main");
1672
1673 let tree = project_tree(root, 2, false);
1674 let src_pos = tree.find("DIR: src").expect("src dir line");
1675 let lib_pos = tree.find("FILE: lib.rs").expect("lib file line");
1676 let main_pos = tree.find("FILE: main.rs").expect("main file line");
1677
1678 assert!(src_pos < lib_pos, "directory must precede its children");
1679 assert!(lib_pos < main_pos, "siblings sorted by name");
1680 }
1681
1682 #[test]
1683 fn project_tree_is_byte_stable_across_calls() {
1684 let tmp = tempdir().expect("tempdir");
1685 let root = tmp.path();
1686 fs::write(root.join("z.txt"), "z").expect("write");
1687 fs::write(root.join("a.txt"), "a").expect("write");
1688
1689 assert_eq!(project_tree(root, 1, false), project_tree(root, 1, false));
1690 }
1691
1692 #[test]
1693 #[cfg(unix)]
1694 fn project_mapping_does_not_follow_symlinked_key_files() {
1695 let tmp = tempdir().expect("tempdir");
1696 let root = tmp.path().join("workspace");
1697 let outside = tmp.path().join("outside");
1698 fs::create_dir_all(&root).expect("mkdir workspace");
1699 fs::create_dir_all(&outside).expect("mkdir outside");
1700 let outside_file = outside.join("Cargo.toml");
1701 fs::write(&outside_file, "[package]\nname = \"outside\"\n").expect("write outside");
1702 std::os::unix::fs::symlink(&outside_file, root.join("Cargo.toml")).expect("symlink");
1703
1704 assert_eq!(summarize_project(&root), "Unknown project type");
1705 assert!(!project_tree(&root, 1, false).contains("Cargo.toml"));
1706 }
1707
1708 #[test]
1709 fn summarize_project_sorts_key_files_in_fallback() {
1710 // When `summarize_project` can't classify a project type it falls
1711 // back to listing the discovered key files. That joined list must
1712 // be deterministic so the system prompt that embeds it doesn't
1713 // drift between runs on filesystems that emit readdir in a
1714 // non-alphabetical order.
1715 let tmp = tempdir().expect("tempdir");
1716 let root = tmp.path();
1717 // Use key files that don't trigger any of the type detectors
1718 // (Cargo.toml / package.json / requirements.txt) so the function
1719 // hits the `Project with key files: …` branch.
1720 fs::write(root.join("Makefile"), "all:").expect("write makefile");
1721 fs::write(root.join("README.md"), "# x").expect("write readme");
1722
1723 let summary = summarize_project(root);
1724 assert!(
1725 summary.starts_with("Project with key files: "),
1726 "expected fallback branch; got: {summary}"
1727 );
1728 let suffix = summary
1729 .strip_prefix("Project with key files: ")
1730 .expect("prefix");
1731 assert_eq!(suffix, "Makefile, README.md");
1732 }
1733
1734 // ===================================================================
1735 // open_url tests
1736 // ===================================================================
1737
1738 #[test]
1739 fn open_url_builds_platform_command_without_spawning() {
1740 let command = super::browser_open_command("https://example.com").expect("command");
1741
1742 #[cfg(target_os = "macos")]
1743 {
1744 assert_eq!(command.get_program(), "open");
1745 assert_eq!(
1746 command
1747 .get_args()
1748 .map(|arg| arg.to_string_lossy().into_owned())
1749 .collect::<Vec<_>>(),
1750 vec!["https://example.com"]
1751 );
1752 }
1753
1754 #[cfg(any(
1755 target_os = "netbsd",
1756 target_os = "freebsd",
1757 target_os = "openbsd",
1758 target_os = "dragonfly"
1759 ))]
1760 {
1761 assert_eq!(command.get_program(), "xdg-open");
1762 }
1763
1764 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1765 {
1766 assert_eq!(command.get_program(), "xdg-open");
1767 assert_eq!(
1768 command
1769 .get_args()
1770 .map(|arg| arg.to_string_lossy().into_owned())
1771 .collect::<Vec<_>>(),
1772 vec!["https://example.com"]
1773 );
1774 }
1775
1776 #[cfg(target_os = "windows")]
1777 {
1778 assert_eq!(command.get_program(), "cmd");
1779 assert_eq!(
1780 command
1781 .get_args()
1782 .map(|arg| arg.to_string_lossy().into_owned())
1783 .collect::<Vec<_>>(),
1784 vec!["/C", "start", "", "https://example.com"]
1785 );
1786 }
1787 }
1788
1789 #[test]
1790 fn open_url_rejects_empty_url_gracefully() {
1791 // An empty URL should fail with a clear error, not panic.
1792 let result = super::browser_open_command("");
1793 match result {
1794 Ok(_) => panic!("empty URL should not build an opener command"),
1795 Err(e) => {
1796 let msg = e.to_string();
1797 assert!(!msg.is_empty(), "error message must not be empty");
1798 assert!(msg.contains("empty"), "unexpected error message: {msg}");
1799 }
1800 }
1801 }
1802 }
1803
1803 lines RUST