返回 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 use std::path::{Path, PathBuf};
6 use std::process::Command;
7
8 use crate::models::{ContentBlock, Message};
9 use anyhow::{Context, Result};
10 use ignore::WalkBuilder;
11 use serde_json::Value;
12 use std::io;
13
14 /// A writer that counts bytes written without storing them.
15 pub(crate) struct CountingWriter {
16 count: usize,
17 }
18
19 impl CountingWriter {
20 pub(crate) fn new() -> Self {
21 Self { count: 0 }
22 }
23
24 pub(crate) fn count(&self) -> usize {
25 self.count
26 }
27 }
28
29 impl io::Write for CountingWriter {
30 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
31 self.count += buf.len();
32 Ok(buf.len())
33 }
34
35 fn flush(&mut self) -> io::Result<()> {
36 Ok(())
37 }
38 }
39
40 const LOG_FINGERPRINT_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
41 const LOG_FINGERPRINT_PRIME: u64 = 0x0000_0100_0000_01b3;
42
43 /// Return a stable, non-reversible log label for an identifier.
44 ///
45 /// This is meant for correlation in diagnostics where the raw value may be a
46 /// session token, remote protocol session id, or other bearer-like handle.
47 #[must_use]
48 pub fn redacted_identifier_for_log(identifier: &str) -> String {
49 if identifier.is_empty() {
50 return "<redacted:empty>".to_string();
51 }
52
53 let mut hash = LOG_FINGERPRINT_OFFSET_BASIS;
54 for byte in identifier.as_bytes() {
55 hash ^= u64::from(*byte);
56 hash = hash.wrapping_mul(LOG_FINGERPRINT_PRIME);
57 }
58 hash ^= identifier.len() as u64;
59 hash = hash.wrapping_mul(LOG_FINGERPRINT_PRIME);
60
61 format!("<redacted:{hash:016x}>")
62 }
63
64 #[cfg(windows)]
65 pub(crate) fn suppress_console_window(cmd: &mut Command) {
66 use std::os::windows::process::CommandExt;
67
68 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
69 cmd.creation_flags(CREATE_NO_WINDOW);
70 }
71
72 #[cfg(not(windows))]
73 pub(crate) fn suppress_console_window(_cmd: &mut Command) {}
74
75 #[cfg(windows)]
76 pub(crate) fn suppress_tokio_console_window(cmd: &mut tokio::process::Command) {
77 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
78 cmd.creation_flags(CREATE_NO_WINDOW);
79 }
80
81 #[cfg(not(windows))]
82 pub(crate) fn suppress_tokio_console_window(_cmd: &mut tokio::process::Command) {}
83
84 // === Project Mapping Helpers ===
85
86 /// Identify if a file is a "key" file for project identification.
87 #[must_use]
88 pub fn is_key_file(path: &Path) -> bool {
89 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
90 return false;
91 };
92
93 matches!(
94 file_name.to_lowercase().as_str(),
95 "cargo.toml"
96 | "package.json"
97 | "requirements.txt"
98 | "build.gradle"
99 | "pom.xml"
100 | "readme.md"
101 | "agents.md"
102 | "claude.md"
103 | "makefile"
104 | "dockerfile"
105 | "main.rs"
106 | "lib.rs"
107 | "index.js"
108 | "index.ts"
109 | "app.py"
110 )
111 }
112
113 /// Generate a high-level summary of the project based on key files.
114 ///
115 /// Output is byte-stable across calls: `WalkBuilder` doesn't sort siblings
116 /// (the OS readdir order leaks through), so the joined `key_files` list
117 /// would otherwise reorder run-to-run on filesystems that don't pre-sort.
118 /// Only matters when the workspace has no `AGENTS.md` / `CLAUDE.md`, since
119 /// the system prompt routes through `ProjectContext::as_system_block` first
120 /// and only falls back here when no project-context document exists.
121 #[must_use]
122 pub fn summarize_project(root: &Path) -> String {
123 let mut key_files = Vec::new();
124
125 let mut builder = WalkBuilder::new(root);
126 builder.hidden(false).follow_links(false).max_depth(Some(2));
127 let walker = builder.build();
128
129 for entry in walker {
130 let entry = match entry {
131 Ok(entry) => entry,
132 Err(_) => continue,
133 };
134 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
135 continue;
136 }
137 if is_key_file(entry.path())
138 && let Ok(rel) = entry.path().strip_prefix(root)
139 {
140 key_files.push(rel.to_string_lossy().to_string());
141 }
142 }
143
144 key_files.sort();
145
146 if key_files.is_empty() {
147 return "Unknown project type".to_string();
148 }
149
150 let mut types = Vec::new();
151 if key_files
152 .iter()
153 .any(|f| f.to_lowercase().contains("cargo.toml"))
154 {
155 types.push("Rust");
156 }
157 if key_files
158 .iter()
159 .any(|f| f.to_lowercase().contains("package.json"))
160 {
161 types.push("JavaScript/Node.js");
162 }
163 if key_files
164 .iter()
165 .any(|f| f.to_lowercase().contains("requirements.txt"))
166 {
167 types.push("Python");
168 }
169
170 if types.is_empty() {
171 format!("Project with key files: {}", key_files.join(", "))
172 } else {
173 format!("A {} project", types.join(" and "))
174 }
175 }
176
177 /// Generate a tree-like view of the project structure.
178 ///
179 /// Sibling order is fixed by sorting collected paths — the underlying
180 /// `WalkBuilder` follows the OS readdir order, which is non-deterministic
181 /// across filesystems. Sorting by full path preserves the tree shape (a
182 /// directory still precedes its children because `"src" < "src/lib.rs"`)
183 /// while making the rendered output byte-stable across runs.
184 #[must_use]
185 pub fn project_tree(root: &Path, max_depth: usize, follow_symlinks: bool) -> String {
186 let mut entries: Vec<(PathBuf, bool)> = Vec::new();
187
188 let mut builder = WalkBuilder::new(root);
189 builder
190 .hidden(false)
191 .follow_links(follow_symlinks)
192 .max_depth(Some(max_depth + 1));
193
194 for entry in builder.build().flatten() {
195 if entry.file_type().is_some_and(|ft| ft.is_symlink()) && !follow_symlinks {
196 continue;
197 }
198 let depth = entry.depth();
199 if depth == 0 || depth > max_depth {
200 continue;
201 }
202 let rel_path = entry
203 .path()
204 .strip_prefix(root)
205 .unwrap_or(entry.path())
206 .to_path_buf();
207 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
208 entries.push((rel_path, is_dir));
209 }
210
211 entries.sort_by(|a, b| a.0.cmp(&b.0));
212
213 let mut tree_lines = Vec::with_capacity(entries.len());
214 for (rel_path, is_dir) in entries {
215 let depth = rel_path.components().count();
216 let indent = " ".repeat(depth.saturating_sub(1));
217 let prefix = if is_dir { "DIR: " } else { "FILE: " };
218 tree_lines.push(format!(
219 "{}{}{}",
220 indent,
221 prefix,
222 rel_path.file_name().unwrap_or_default().to_string_lossy()
223 ));
224 }
225
226 tree_lines.join("\n")
227 }
228
229 // === Filesystem Helpers ===
230
231 /// Permission policy for atomic writes.
232 ///
233 /// - [`AtomicWritePermissions::Private`]: keep tempfile's owner-only defaults
234 /// (used for CodeWhale internal persistence such as session/history/trust).
235 /// - [`AtomicWritePermissions::Workspace`]: match ordinary workspace file
236 /// semantics — new files request mode `0666` (kernel applies umask); existing
237 /// files retain ordinary `rwx` bits (not setuid/setgid/sticky).
238 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
239 enum AtomicWritePermissions {
240 Private,
241 Workspace,
242 }
243
244 /// Atomically write `contents` to `path` using a temporary file + fsync + rename.
245 ///
246 /// Uses a **private** permission policy (Unix tempfile default `0600`). Prefer
247 /// [`write_atomic_workspace`] for user workspace source/config files.
248 ///
249 /// 1. Creates a `NamedTempFile` in the same directory as `path` (same filesystem).
250 /// 2. Writes `contents` to the temp file.
251 /// 3. Calls `sync_all()` on the temp file for durability.
252 /// 4. Atomically renames (persists) the temp file over `path`.
253 ///
254 /// On filesystems that support it (`ext4`, `apfs`, `ntfs`), the rename is
255 /// atomic — a concurrent reader sees either the old content or the new, never
256 /// a partial write. `sync_all` ensures the data is on stable storage before
257 /// the metadata change so an OS crash mid-rename doesn't lose data.
258 ///
259 /// # Errors
260 /// Returns `io::Error` if the parent directory cannot be determined, the temp
261 /// file cannot be created, the write fails, or the rename fails.
262 pub fn write_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> {
263 write_atomic_with_permissions(path, contents, AtomicWritePermissions::Private)
264 }
265
266 /// Atomically write `contents` to a **user workspace** path.
267 ///
268 /// On Unix:
269 /// - New files request creation mode `0666`; the OS applies the process umask
270 /// (same candidate mode as ordinary `std::fs::write`).
271 /// - Existing files keep ordinary permission bits (`mode & 0o777`), including
272 /// executable bits. setuid/setgid/sticky are intentionally not restored.
273 ///
274 /// On Windows this matches [`write_atomic`] (no POSIX mode simulation).
275 ///
276 /// # Errors
277 /// Same failure modes as [`write_atomic`].
278 pub fn write_atomic_workspace(path: &Path, contents: &[u8]) -> std::io::Result<()> {
279 write_atomic_with_permissions(path, contents, AtomicWritePermissions::Workspace)
280 }
281
282 fn write_atomic_with_permissions(
283 path: &Path,
284 contents: &[u8],
285 #[cfg_attr(not(unix), allow(unused_variables))] permission_policy: AtomicWritePermissions,
286 ) -> std::io::Result<()> {
287 let parent = path.parent().ok_or_else(|| {
288 std::io::Error::new(
289 std::io::ErrorKind::InvalidInput,
290 format!("path has no parent directory: {}", path.display()),
291 )
292 })?;
293
294 // Capture ordinary rwx bits before replacement. Use symlink_metadata so we
295 // do not follow links: an inaccessible or dangling symlink target must not
296 // abort the write — rename still replaces the directory entry, matching
297 // the pre-#4606 private write_atomic behavior. Symlink entries themselves
298 // are treated as "no mode to preserve" (new ordinary file after rename);
299 // only regular-file modes are restored. Mask with 0o777 so setuid/setgid/
300 // sticky are never restored after rewriting content.
301 #[cfg(unix)]
302 let existing_workspace_mode = if permission_policy == AtomicWritePermissions::Workspace {
303 match fs::symlink_metadata(path) {
304 Ok(metadata) if metadata.file_type().is_symlink() => None,
305 Ok(metadata) => {
306 use std::os::unix::fs::PermissionsExt;
307 Some(metadata.permissions().mode() & 0o777)
308 }
309 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
310 Err(err) => return Err(err),
311 }
312 } else {
313 None
314 };
315
316 // Use parent directory so the rename is on the same filesystem.
317 #[cfg(unix)]
318 let mut builder = tempfile::Builder::new();
319 #[cfg(not(unix))]
320 let builder = tempfile::Builder::new();
321
322 // New workspace files should behave like ordinary files opened with
323 // creation mode 0666. The kernel applies the inherited process umask.
324 // Do NOT chmod after create: set_permissions bypasses umask.
325 #[cfg(unix)]
326 if permission_policy == AtomicWritePermissions::Workspace && existing_workspace_mode.is_none() {
327 use std::os::unix::fs::PermissionsExt;
328 builder.permissions(fs::Permissions::from_mode(0o666));
329 }
330
331 let mut tmp = builder.tempfile_in(parent)?;
332 std::io::Write::write_all(&mut tmp, contents)?;
333
334 // Atomic replacement creates a new inode. Restore ordinary access /
335 // executable bits of an existing workspace file before persisting.
336 #[cfg(unix)]
337 if let Some(mode) = existing_workspace_mode {
338 use std::os::unix::fs::PermissionsExt;
339 tmp.as_file()
340 .set_permissions(fs::Permissions::from_mode(mode))?;
341 }
342
343 tmp.as_file().sync_all()?;
344 #[cfg(windows)]
345 {
346 // Windows can briefly deny replacement while Defender, indexing, or a
347 // concurrent reader still holds the destination without delete sharing.
348 // Keep the already-synced tempfile and retry only the transient Win32
349 // sharing/lock failures; permanent permission errors still surface.
350 const MAX_PERSIST_ATTEMPTS: usize = 6;
351 let mut pending = tmp;
352 for attempt in 0..MAX_PERSIST_ATTEMPTS {
353 match pending.persist(path) {
354 Ok(_) => break,
355 Err(err) => {
356 let retryable = err.error.kind() == std::io::ErrorKind::PermissionDenied
357 || matches!(err.error.raw_os_error(), Some(5 | 32 | 33));
358 if !retryable || attempt + 1 == MAX_PERSIST_ATTEMPTS {
359 return Err(err.error);
360 }
361 pending = err.file;
362 std::thread::sleep(std::time::Duration::from_millis(
363 10u64.saturating_mul(1u64 << attempt),
364 ));
365 }
366 }
367 }
368 }
369 #[cfg(not(windows))]
370 tmp.persist(path)?;
371 // Fsync the parent directory so the rename (the new directory entry) is
372 // itself durable — otherwise a power loss right after the rename can lose
373 // it even though the file data was synced, silently dropping a
374 // crash-recovery checkpoint. Best-effort: not all platforms permit
375 // opening a directory for sync, so a failure here is not fatal.
376 if let Ok(dir) = std::fs::File::open(parent) {
377 let _ = dir.sync_all();
378 }
379 Ok(())
380 }
381
382 /// Open or create a file for appending at `path`, optionally syncing after
383 /// every write. Use this for append-only logs like `audit.log`.
384 ///
385 /// The returned `BufWriter<fs::File>` wraps the append handle. Call
386 /// `.flush()` followed by `.get_ref().sync_all()` after each batch.
387 pub fn open_append(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
388 if let Some(parent) = path.parent() {
389 std::fs::create_dir_all(parent)?;
390 }
391 let file = std::fs::OpenOptions::new()
392 .create(true)
393 .append(true)
394 .open(path)?;
395 Ok(std::io::BufWriter::new(file))
396 }
397
398 /// Flush a `BufWriter` wrapping a `File`, then `fsync` the underlying file.
399 pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> std::io::Result<()> {
400 writer.flush()?;
401 writer.get_ref().sync_all()
402 }
403
404 /// Open a URL in the system's default browser.
405 ///
406 /// Dispatches to the platform-appropriate opener:
407 /// - macOS: `open`
408 /// - Linux / BSD: `xdg-open`
409 /// - Windows: `cmd /C start ""`
410 /// - Other: returns an error.
411 ///
412 /// This is the single entry point for URL opening — every call site in
413 /// the codebase should use this instead of hardcoding `Command::new("open")`,
414 /// `Command::new("xdg-open")`, or `Command::new("cmd")`.
415 pub fn open_url(url: &str) -> Result<()> {
416 let mut command = browser_open_command(url)?;
417 command
418 .stdout(std::process::Stdio::null())
419 .stderr(std::process::Stdio::null())
420 .spawn()
421 .map(|_| ())
422 .map_err(|e| anyhow::anyhow!("failed to launch browser command: {e}"))
423 }
424
425 fn browser_open_command(url: &str) -> Result<Command> {
426 if url.trim().is_empty() {
427 return Err(anyhow::anyhow!("browser URL cannot be empty"));
428 }
429
430 #[cfg(target_os = "macos")]
431 {
432 let mut command = Command::new("open");
433 command.arg(url);
434 Ok(command)
435 }
436
437 #[cfg(any(
438 all(target_os = "linux", not(target_env = "ohos")),
439 target_os = "netbsd",
440 target_os = "freebsd",
441 target_os = "openbsd",
442 target_os = "dragonfly"
443 ))]
444 {
445 let mut command = Command::new("xdg-open");
446 command.arg(url);
447 Ok(command)
448 }
449
450 #[cfg(target_os = "windows")]
451 {
452 let mut cmd = Command::new("cmd");
453 cmd.args(["/C", "start", "", url]);
454 Ok(cmd)
455 }
456
457 #[cfg(not(any(
458 target_os = "macos",
459 all(target_os = "linux", not(target_env = "ohos")),
460 target_os = "windows",
461 target_os = "netbsd",
462 target_os = "freebsd",
463 target_os = "openbsd",
464 target_os = "dragonfly"
465 )))]
466 Err(anyhow::anyhow!(
467 "browser opening is unsupported on this platform"
468 ))
469 }
470
471 /// Spawn a tokio task with panic supervision.
472 ///
473 /// Wraps the future in `AssertUnwindSafe` + `catch_unwind`. On panic:
474 /// 1. Logs the panic with the task name and caller location via `tracing::error!`.
475 /// 2. Writes a crash dump to `~/.codewhale/crashes/<timestamp>-<name>.log`.
476 ///
477 /// The returned `JoinHandle` resolves to `()` — the panic is caught and
478 /// handled internally so the parent process stays alive.
479 pub fn spawn_supervised<F>(
480 name: &'static str,
481 location: &'static std::panic::Location<'static>,
482 future: F,
483 ) -> tokio::task::JoinHandle<()>
484 where
485 F: std::future::Future<Output = ()> + Send + 'static,
486 {
487 tokio::spawn(async move {
488 use futures_util::FutureExt;
489 let result = std::panic::AssertUnwindSafe(future).catch_unwind().await;
490 if let Err(panic_info) = result {
491 let msg = panic_message(&*panic_info);
492 tracing::error!(
493 target: "panic",
494 "Task '{name}' panicked at {}: {msg}",
495 location,
496 );
497 // Write crash dump (best-effort)
498 let _ = write_panic_dump(name, location, &msg);
499 }
500 })
501 }
502
503 /// Extract a human-readable message from a caught panic payload (the `Err`
504 /// value of `catch_unwind`). Mirrors how the panic hook formats `&str` and
505 /// `String` payloads so crash dumps stay consistent across call sites.
506 #[must_use]
507 pub fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
508 if let Some(s) = panic.downcast_ref::<&str>() {
509 (*s).to_string()
510 } else if let Some(s) = panic.downcast_ref::<String>() {
511 s.clone()
512 } else {
513 "unknown panic".to_string()
514 }
515 }
516
517 /// Record a panic that was caught at a call site (via `catch_unwind`) rather
518 /// than by a task supervisor. Logs it on the `panic` target and writes a
519 /// best-effort crash dump to `~/.codewhale/crashes/`, so diagnostics land in
520 /// the same place `spawn_supervised` writes them even when the caller recovers
521 /// and keeps running.
522 #[track_caller]
523 pub fn record_caught_panic(name: &'static str, message: &str) {
524 let location = std::panic::Location::caller();
525 tracing::error!(target: "panic", "Task '{name}' panicked at {location}: {message}");
526 let _ = write_panic_dump(name, location, message);
527 // A caught panic is still a panic. The site is allowlist-reduced to
528 // `crates/…` or the literal `<dep>`, and `message` is deliberately not
529 // read: a slicing panic embeds the entire string being sliced. The exit
530 // class is left alone — the caller recovered, so this process is not
531 // ending here. A no-op unless this process was armed.
532 codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic {
533 site: codewhale_telemetry::reduce_panic_site(
534 location.file(),
535 location.line(),
536 location.column(),
537 ),
538 });
539 }
540
541 /// Write a panic dump file to `~/.codewhale/crashes/`.
542 ///
543 /// Creates the directory if needed and writes a timestamped log
544 /// with the task name, caller location, and panic message.
545 /// Best-effort — failures are silently ignored.
546 fn write_panic_dump(
547 name: &str,
548 location: &std::panic::Location<'_>,
549 message: &str,
550 ) -> std::io::Result<()> {
551 let home = crate::config::effective_home_dir().ok_or_else(|| {
552 std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found")
553 })?;
554 // Prefer .codewhale, fall back to .deepseek
555 let crash_dir = home.join(".codewhale").join("crashes");
556 if !crash_dir.exists() {
557 // Try legacy path for reading, but prefer new for writing
558 let _ = std::fs::create_dir_all(&crash_dir);
559 }
560 let crash_dir = if crash_dir.exists() {
561 crash_dir
562 } else {
563 home.join(".deepseek").join("crashes")
564 };
565 write_panic_dump_to(&crash_dir, name, location, message)
566 }
567
568 fn write_panic_dump_to(
569 crash_dir: &Path,
570 name: &str,
571 location: &std::panic::Location<'_>,
572 message: &str,
573 ) -> std::io::Result<()> {
574 use chrono::Utc;
575 std::fs::create_dir_all(crash_dir)?;
576 let timestamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
577 let filename = format!("{timestamp}-{name}.log");
578 let path = crash_dir.join(&filename);
579 let contents =
580 format!("Task: {name}\nLocation: {location}\nTimestamp: {timestamp}\nPanic: {message}\n");
581 std::fs::write(&path, contents)?;
582 Ok(())
583 }
584
585 /// Fire-and-forget `spawn_blocking` with panic dump protection.
586 ///
587 /// In contrast to `spawn_supervised` (which wraps `tokio::spawn` for async
588 /// tasks), this helper wraps `tokio::task::spawn_blocking`. Use it when a
589 /// CPU-bound or blocking-I/O task must run off the async runtime and its
590 /// completion is *not* awaited — for example a post-turn disk snapshot or a
591 /// file-tree build polled later via a shared data structure. If the closure
592 /// panics, a crash dump is written to `~/.codewhale/crashes/` and the panic
593 /// is logged at ERROR level rather than being silently swallowed.
594 #[track_caller]
595 pub fn spawn_blocking_supervised<F>(name: &'static str, f: F) -> tokio::task::JoinHandle<()>
596 where
597 F: FnOnce() + Send + 'static,
598 {
599 let location = std::panic::Location::caller();
600 tokio::task::spawn_blocking(move || {
601 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
602 if let Err(panic_info) = result {
603 let msg = panic_message(&*panic_info);
604 tracing::error!(
605 target: "panic",
606 "Blocking task '{name}' panicked at {location}: {msg}",
607 );
608 let _ = write_panic_dump(name, location, &msg);
609 }
610 })
611 }
612
613 #[allow(dead_code)]
614 pub fn ensure_dir(path: &Path) -> Result<()> {
615 fs::create_dir_all(path)
616 .with_context(|| format!("Failed to create directory: {}", path.display()))
617 }
618
619 /// Render JSON with pretty formatting, falling back to a compact string on error.
620 #[must_use]
621 #[allow(dead_code)]
622 pub fn pretty_json(value: &Value) -> String {
623 serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
624 }
625
626 /// Truncate a string to a maximum length, adding an ellipsis if truncated.
627 ///
628 /// Uses char boundaries to avoid panicking on multi-byte UTF-8 characters.
629 #[must_use]
630 pub fn truncate_with_ellipsis(s: &str, max_len: usize, ellipsis: &str) -> String {
631 if s.len() <= max_len {
632 return s.to_string();
633 }
634 let budget = max_len.saturating_sub(ellipsis.len());
635 // Find the last char boundary that fits within the byte budget.
636 let safe_end = s
637 .char_indices()
638 .map(|(i, _)| i)
639 .take_while(|&i| i <= budget)
640 .last()
641 .unwrap_or(0);
642 format!("{}{}", &s[..safe_end], ellipsis)
643 }
644
645 /// Percent-encode a string for use in URL query parameters.
646 ///
647 /// Encodes all characters except unreserved characters (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`).
648 /// Spaces are encoded as `+`.
649 #[must_use]
650 pub fn url_encode(input: &str) -> String {
651 let mut encoded = String::new();
652 for ch in input.bytes() {
653 match ch {
654 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
655 encoded.push(ch as char)
656 }
657 b' ' => encoded.push('+'),
658 _ => encoded.push_str(&format!("%{ch:02X}")),
659 }
660 }
661 encoded
662 }
663
664 /// Render a path for **user-facing display** with the home directory
665 /// contracted to `~`. Use this in the TUI, doctor/setup stdout, and any
666 /// other place a viewer might see the output (screenshot, video,
667 /// pasted-into-issue help). On macOS/Linux the absolute path
668 /// `/Users/<name>/...` or `/home/<name>/...` reveals the OS account name,
669 /// which is often the same as a public handle — undesirable for users
670 /// who share their terminal.
671 ///
672 /// **Do not use** this for paths that get persisted (sessions, audit log)
673 /// or sent to the LLM provider — those want full fidelity so they
674 /// resolve correctly across processes.
675 #[must_use]
676 pub fn display_path(path: &Path) -> String {
677 display_path_with_home(path, crate::config::effective_home_dir().as_deref())
678 }
679
680 /// Like [`display_path`] but takes an explicit home directory instead of
681 /// reading `$HOME` / `crate::config::effective_home_dir()`. Used in tests and anywhere the
682 /// caller already has the home path available.
683 ///
684 /// The home-relative suffix is rejoined with the platform separator
685 /// (`\` on Windows, `/` elsewhere) by walking the path's components, so
686 /// inputs that carried foreign separators don't leak through.
687 #[must_use]
688 pub fn display_path_with_home(path: &Path, home: Option<&Path>) -> String {
689 let Some(home) = home else {
690 return path.display().to_string();
691 };
692 if let Ok(rest) = path.strip_prefix(home) {
693 if rest.as_os_str().is_empty() {
694 return "~".to_string();
695 }
696 let sep = std::path::MAIN_SEPARATOR_STR;
697 let mut out = String::from("~");
698 for component in rest.components() {
699 out.push_str(sep);
700 out.push_str(&component.as_os_str().to_string_lossy());
701 }
702 return out;
703 }
704 path.display().to_string()
705 }
706
707 /// Estimate the total character count across message content blocks.
708 #[must_use]
709 pub fn estimate_message_chars(messages: &[Message]) -> usize {
710 let mut total = 0;
711 for msg in messages {
712 for block in &msg.content {
713 match block {
714 ContentBlock::Text { text, .. } => total += text.len(),
715 ContentBlock::Thinking { thinking, .. } => total += thinking.len(),
716 ContentBlock::ToolUse { input, .. } => {
717 let mut cw = CountingWriter::new();
718 let _ = serde_json::to_writer(&mut cw, input);
719 total += cw.count();
720 }
721 ContentBlock::ToolResult { content, .. } => total += content.len(),
722 ContentBlock::ServerToolUse { .. }
723 | ContentBlock::ToolSearchToolResult { .. }
724 | ContentBlock::CodeExecutionToolResult { .. }
725 | ContentBlock::ImageUrl { .. } => {}
726 }
727 }
728 }
729 total
730 }
731
732 // Tests use `display_path_with_home` so they never mutate the global `HOME`
733 // env var. Mutating `HOME` via `std::env::set_var` is not thread-safe; Cargo
734 // runs tests in parallel by default and CI runners are multi-core, so any test
735 // that stomps `HOME` will race with tests that *read* it. Using the injected
736 // helper avoids the race entirely and makes the tests portable to Windows
737 // without additional platform scaffolding.
738 #[cfg(test)]
739 mod tests {
740 use super::{display_path_with_home, redacted_identifier_for_log};
741 use std::path::PathBuf;
742
743 fn home(s: &str) -> Option<PathBuf> {
744 Some(PathBuf::from(s))
745 }
746
747 #[test]
748 fn redacted_identifier_for_log_hides_value_and_stays_stable() {
749 let identifier = "session-secret-1234567890";
750 let redacted = redacted_identifier_for_log(identifier);
751
752 assert!(redacted.starts_with("<redacted:"));
753 assert!(redacted.ends_with('>'));
754 assert!(!redacted.contains(identifier));
755 assert_eq!(redacted, redacted_identifier_for_log(identifier));
756 assert_ne!(redacted, redacted_identifier_for_log("another-session"));
757 }
758
759 #[test]
760 fn redacted_identifier_for_log_marks_empty_values() {
761 assert_eq!(redacted_identifier_for_log(""), "<redacted:empty>");
762 }
763
764 #[test]
765 fn display_path_contracts_home_prefix() {
766 let h = home("/Users/alice");
767 assert_eq!(
768 display_path_with_home(&PathBuf::from("/Users/alice/projects/foo"), h.as_deref()),
769 format!(
770 "~{}projects{}foo",
771 std::path::MAIN_SEPARATOR,
772 std::path::MAIN_SEPARATOR
773 ),
774 );
775 }
776
777 #[test]
778 fn display_path_returns_bare_tilde_for_home_itself() {
779 let h = home("/Users/alice");
780 assert_eq!(
781 display_path_with_home(&PathBuf::from("/Users/alice"), h.as_deref()),
782 "~"
783 );
784 }
785
786 #[test]
787 fn display_path_leaves_unrelated_paths_alone() {
788 let h = home("/Users/alice");
789 // Different user — must not get rewritten or share the tilde.
790 assert_eq!(
791 display_path_with_home(&PathBuf::from("/Users/bob/Code"), h.as_deref()),
792 "/Users/bob/Code".to_string()
793 );
794 // System path must stay absolute.
795 assert_eq!(
796 display_path_with_home(&PathBuf::from("/etc/hosts"), h.as_deref()),
797 "/etc/hosts"
798 );
799 }
800
801 #[test]
802 fn display_path_does_not_match_username_prefix() {
803 // Regression guard: a directory named like the user's home
804 // *prefix* but not under it must not get rewritten.
805 let h = home("/Users/alice");
806 assert_eq!(
807 display_path_with_home(&PathBuf::from("/Users/alice2/work"), h.as_deref()),
808 "/Users/alice2/work"
809 );
810 }
811
812 #[test]
813 fn display_path_with_no_home_returns_full_path() {
814 assert_eq!(
815 display_path_with_home(&PathBuf::from("/some/path"), None),
816 "/some/path"
817 );
818 }
819 }
820
821 #[cfg(test)]
822 mod atomic_write_tests {
823 use super::*;
824 use std::fs;
825 use tempfile::tempdir;
826
827 #[test]
828 fn write_atomic_writes_content() {
829 let tmp = tempdir().expect("tempdir");
830 let path = tmp.path().join("test.json");
831 let content = b"hello atomic world";
832
833 write_atomic(&path, content).expect("write_atomic");
834 assert!(path.exists());
835 let read = fs::read_to_string(&path).expect("read");
836 assert_eq!(read.as_bytes(), content);
837 }
838
839 #[test]
840 fn write_atomic_replaces_existing_file() {
841 let tmp = tempdir().expect("tempdir");
842 let path = tmp.path().join("existing.json");
843 fs::write(&path, b"old content").expect("write old");
844 write_atomic(&path, b"new content").expect("write_atomic");
845 let read = fs::read_to_string(&path).expect("read");
846 assert_eq!(read, "new content");
847 }
848
849 #[cfg(windows)]
850 #[test]
851 fn write_atomic_retries_windows_replace_contention() {
852 use std::os::windows::fs::OpenOptionsExt;
853
854 let tmp = tempdir().expect("tempdir");
855 let path = tmp.path().join("contended.json");
856 fs::write(&path, b"old content").expect("write old");
857
858 // FILE_SHARE_READ | FILE_SHARE_WRITE deliberately omits
859 // FILE_SHARE_DELETE, reproducing the short-lived handle contention
860 // that makes MoveFileExW report access denied during replacement.
861 let held = fs::OpenOptions::new()
862 .read(true)
863 .share_mode(0x1 | 0x2)
864 .open(&path)
865 .expect("hold destination without delete sharing");
866 let release = std::thread::spawn(move || {
867 std::thread::sleep(std::time::Duration::from_millis(50));
868 drop(held);
869 });
870
871 write_atomic(&path, b"new content").expect("retry contended atomic replacement");
872 release.join().expect("release destination handle");
873 assert_eq!(fs::read(&path).expect("read replacement"), b"new content");
874 }
875
876 #[test]
877 fn write_atomic_no_temp_left_behind_on_success() {
878 let tmp = tempdir().expect("tempdir");
879 let path = tmp.path().join("clean.json");
880 write_atomic(&path, b"clean").expect("write_atomic");
881 // List files in dir — there should be no .tmp files left
882 let entries: Vec<_> = fs::read_dir(tmp.path())
883 .expect("read_dir")
884 .filter_map(|e| e.ok())
885 .collect();
886 let tmp_files: Vec<_> = entries
887 .iter()
888 .filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with('.')))
889 .collect();
890 assert!(
891 tmp_files.is_empty(),
892 "temp files left behind: {tmp_files:?}"
893 );
894 }
895
896 #[cfg(unix)]
897 #[test]
898 fn write_atomic_workspace_new_file_matches_standard_creation_mode() {
899 use std::os::unix::fs::PermissionsExt;
900
901 let dir = tempdir().expect("tempdir");
902 let control = dir.path().join("control.txt");
903 let actual = dir.path().join("actual.txt");
904
905 fs::write(&control, b"control").expect("write control");
906 write_atomic_workspace(&actual, b"actual").expect("atomic workspace write");
907
908 let control_mode = fs::metadata(&control)
909 .expect("control metadata")
910 .permissions()
911 .mode()
912 & 0o777;
913 let actual_mode = fs::metadata(&actual)
914 .expect("actual metadata")
915 .permissions()
916 .mode()
917 & 0o777;
918
919 assert_eq!(actual_mode, control_mode);
920 assert_eq!(fs::read(&actual).expect("read"), b"actual");
921 }
922
923 #[cfg(unix)]
924 #[test]
925 fn write_atomic_workspace_preserves_existing_mode() {
926 use std::os::unix::fs::PermissionsExt;
927
928 let dir = tempdir().expect("tempdir");
929 let path = dir.path().join("shared.txt");
930 fs::write(&path, b"before").expect("initial write");
931 fs::set_permissions(&path, fs::Permissions::from_mode(0o664))
932 .expect("set shared permissions");
933
934 write_atomic_workspace(&path, b"after").expect("atomic workspace write");
935
936 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
937 assert_eq!(mode, 0o664);
938 assert_eq!(fs::read(&path).expect("read"), b"after");
939 }
940
941 #[cfg(unix)]
942 #[test]
943 fn write_atomic_workspace_preserves_executable_bits() {
944 use std::os::unix::fs::PermissionsExt;
945
946 let dir = tempdir().expect("tempdir");
947 let path = dir.path().join("script.sh");
948 fs::write(&path, b"#!/bin/sh\nexit 0\n").expect("initial write");
949 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
950 .expect("set executable permissions");
951
952 write_atomic_workspace(&path, b"#!/bin/sh\nexit 1\n").expect("atomic workspace write");
953
954 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
955 assert_eq!(mode, 0o755);
956 assert_eq!(fs::read(&path).expect("read"), b"#!/bin/sh\nexit 1\n");
957 }
958
959 #[cfg(unix)]
960 #[test]
961 fn write_atomic_workspace_does_not_restore_special_bits() {
962 use std::os::unix::fs::PermissionsExt;
963
964 let dir = tempdir().expect("tempdir");
965 let path = dir.path().join("special.sh");
966 fs::write(&path, b"#!/bin/sh\n").expect("initial write");
967 // Request sticky + setgid + rwxr-xr-x. Filesystems may clear some
968 // special bits; we only assert that after rewrite we never keep
969 // bits outside the ordinary 0o777 mask.
970 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o6755));
971 let before = fs::metadata(&path)
972 .expect("metadata before")
973 .permissions()
974 .mode();
975 let expected_ordinary = before & 0o777;
976
977 write_atomic_workspace(&path, b"#!/bin/sh\necho rewritten\n")
978 .expect("atomic workspace write");
979
980 let after = fs::metadata(&path)
981 .expect("metadata after")
982 .permissions()
983 .mode();
984 assert_eq!(after & 0o777, expected_ordinary);
985 // `PermissionsExt::mode()` also contains the regular-file type bit on
986 // macOS/BSD. Check only the Unix special permission bits rather than
987 // treating every non-rwx bit as a restored permission.
988 assert_eq!(after & 0o7000, 0, "special bits must not be restored");
989 }
990
991 #[cfg(unix)]
992 #[test]
993 fn write_atomic_workspace_replaces_symlink_without_following_target() {
994 use std::os::unix::fs::{PermissionsExt, symlink};
995
996 let dir = tempdir().expect("tempdir");
997 let target = dir.path().join("target.txt");
998 let link = dir.path().join("link.txt");
999 fs::write(&target, b"target-body").expect("write target");
1000 fs::set_permissions(&target, fs::Permissions::from_mode(0o600))
1001 .expect("lock down target mode");
1002 symlink(&target, &link).expect("create symlink");
1003
1004 write_atomic_workspace(&link, b"replaced-link")
1005 .expect("workspace write must replace symlink directory entry");
1006
1007 let link_meta = fs::symlink_metadata(&link).expect("link metadata");
1008 assert!(
1009 link_meta.file_type().is_file() && !link_meta.file_type().is_symlink(),
1010 "rename should replace the symlink with a regular file"
1011 );
1012 assert_eq!(fs::read(&link).expect("read link path"), b"replaced-link");
1013 // Target inode must remain untouched (old private write_atomic semantics).
1014 assert_eq!(fs::read(&target).expect("read target"), b"target-body");
1015 assert_eq!(
1016 fs::metadata(&target)
1017 .expect("target metadata")
1018 .permissions()
1019 .mode()
1020 & 0o777,
1021 0o600
1022 );
1023 }
1024
1025 #[cfg(unix)]
1026 #[test]
1027 fn write_atomic_workspace_replaces_symlink_when_target_is_unreadable() {
1028 use std::os::unix::fs::{PermissionsExt, symlink};
1029
1030 let dir = tempdir().expect("tempdir");
1031 let secret_dir = dir.path().join("secret");
1032 fs::create_dir(&secret_dir).expect("create secret dir");
1033 let secret_file = secret_dir.join("hidden.txt");
1034 fs::write(&secret_file, b"hidden").expect("write hidden");
1035 // Remove search permission so following the symlink fails with EACCES,
1036 // while lstat on the symlink itself still succeeds.
1037 fs::set_permissions(&secret_dir, fs::Permissions::from_mode(0o000))
1038 .expect("lock secret dir");
1039
1040 let link = dir.path().join("to-hidden.txt");
1041 symlink(&secret_file, &link).expect("symlink to hidden file");
1042
1043 // Following metadata must fail; workspace write must still succeed.
1044 assert!(
1045 fs::metadata(&link).is_err(),
1046 "precondition: following the symlink must fail"
1047 );
1048
1049 let result = write_atomic_workspace(&link, b"new-content");
1050
1051 // Restore dir perms so tempdir cleanup can remove nested files.
1052 let _ = fs::set_permissions(&secret_dir, fs::Permissions::from_mode(0o700));
1053
1054 result.expect("workspace write must not abort when symlink target is unreadable");
1055 let link_meta = fs::symlink_metadata(&link).expect("link metadata");
1056 assert!(link_meta.file_type().is_file() && !link_meta.file_type().is_symlink());
1057 assert_eq!(fs::read(&link).expect("read"), b"new-content");
1058 }
1059
1060 #[cfg(unix)]
1061 #[test]
1062 fn write_atomic_private_new_file_does_not_gain_group_or_other_access() {
1063 use std::os::unix::fs::PermissionsExt;
1064
1065 let dir = tempdir().expect("tempdir");
1066 let path = dir.path().join("private.json");
1067
1068 write_atomic(&path, b"{}").expect("private atomic write");
1069
1070 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
1071 assert_eq!(mode & 0o077, 0);
1072 }
1073
1074 #[test]
1075 fn write_atomic_workspace_writes_content() {
1076 let tmp = tempdir().expect("tempdir");
1077 let path = tmp.path().join("workspace.txt");
1078 write_atomic_workspace(&path, b"workspace").expect("write_atomic_workspace");
1079 assert_eq!(fs::read(&path).expect("read"), b"workspace");
1080 }
1081
1082 #[test]
1083 fn flush_and_sync_writes_and_syncs() {
1084 let tmp = tempdir().expect("tempdir");
1085 let path = tmp.path().join("append.log");
1086 {
1087 let mut writer = open_append(&path).expect("open_append");
1088 writeln!(writer, "line 1").expect("write");
1089 flush_and_sync(&mut writer).expect("flush_and_sync");
1090 writeln!(writer, "line 2").expect("write");
1091 flush_and_sync(&mut writer).expect("flush_and_sync");
1092 }
1093 let content = fs::read_to_string(&path).expect("read");
1094 assert_eq!(content, "line 1\nline 2\n");
1095 }
1096 }
1097
1098 #[cfg(test)]
1099 mod spawn_supervised_tests {
1100 use super::*;
1101 use std::sync::Arc;
1102 use std::sync::atomic::{AtomicBool, Ordering};
1103
1104 /// A spawned task that panics does not propagate the panic to the
1105 /// parent task — `spawn_supervised` catches it. Verified in isolation
1106 /// from the on-disk crash-dump path so the test is portable across
1107 /// macOS / Linux / Windows (where `crate::config::effective_home_dir()` reads
1108 /// `USERPROFILE`, not `HOME`, so env-mutation tricks don't redirect
1109 /// the dump on Windows).
1110 #[tokio::test]
1111 async fn panicking_task_does_not_propagate_to_parent() {
1112 let parent_alive = Arc::new(AtomicBool::new(false));
1113 let parent_alive_clone = parent_alive.clone();
1114
1115 let handle = spawn_supervised(
1116 "panic-test-fixture",
1117 std::panic::Location::caller(),
1118 async move {
1119 parent_alive_clone.store(true, Ordering::SeqCst);
1120 panic!("deliberate panic for catch-unwind test");
1121 },
1122 );
1123
1124 let result = handle.await;
1125 assert!(
1126 result.is_ok(),
1127 "spawn_supervised must convert panic to a normal completion"
1128 );
1129 assert!(
1130 parent_alive.load(Ordering::SeqCst),
1131 "fixture task must have run before panicking"
1132 );
1133 }
1134
1135 #[tokio::test]
1136 async fn panicking_blocking_task_does_not_propagate_to_parent() {
1137 let parent_alive = Arc::new(AtomicBool::new(false));
1138 let parent_alive_clone = parent_alive.clone();
1139
1140 let handle = spawn_blocking_supervised("blocking-panic-test-fixture", move || {
1141 parent_alive_clone.store(true, Ordering::SeqCst);
1142 panic!("deliberate panic for spawn_blocking catch-unwind test");
1143 });
1144
1145 let result = handle.await;
1146 assert!(
1147 result.is_ok(),
1148 "spawn_blocking_supervised must convert panic to a normal completion"
1149 );
1150 assert!(
1151 parent_alive.load(Ordering::SeqCst),
1152 "fixture blocking task must have run before panicking"
1153 );
1154 }
1155
1156 /// `write_panic_dump_to` writes a properly-formatted crash log into
1157 /// the supplied directory. Tested separately from `spawn_supervised`
1158 /// because env-mutation redirection of `crate::config::effective_home_dir()` doesn't
1159 /// work on Windows.
1160 #[test]
1161 fn write_panic_dump_writes_named_log() {
1162 let tmp = tempfile::tempdir().expect("tempdir");
1163 let crash_dir = tmp.path().join("crashes");
1164 let location = std::panic::Location::caller();
1165 write_panic_dump_to(&crash_dir, "panic-fixture", location, "boom").expect("write dump");
1166
1167 let entries: Vec<_> = std::fs::read_dir(&crash_dir)
1168 .expect("crashes dir exists")
1169 .flatten()
1170 .collect();
1171 assert_eq!(entries.len(), 1, "exactly one crash dump expected");
1172 let dump = std::fs::read_to_string(entries[0].path()).expect("read dump");
1173 assert!(
1174 dump.contains("panic-fixture"),
1175 "dump must include the task name; got: {dump}"
1176 );
1177 assert!(
1178 dump.contains("boom"),
1179 "dump must include the panic message; got: {dump}"
1180 );
1181 }
1182 }
1183
1184 #[cfg(test)]
1185 mod project_mapping_tests {
1186 use super::{project_tree, summarize_project};
1187 use std::fs;
1188 use tempfile::tempdir;
1189
1190 #[test]
1191 fn project_tree_sorts_siblings_alphabetically() {
1192 // Cross-platform readdir doesn't guarantee alphabetical order — on
1193 // ext4 with htree it's hash order, on APFS it's roughly insertion
1194 // order, on ZFS it's storage-class dependent. The system prompt
1195 // embeds this string in the cached prefix when a workspace has no
1196 // AGENTS.md / CLAUDE.md, so the function has to be byte-stable
1197 // across runs regardless of host filesystem.
1198 let tmp = tempdir().expect("tempdir");
1199 let root = tmp.path();
1200 // Create files in a deliberately scrambled order to make the
1201 // hosting filesystem's pre-sort (if any) less likely to mask a
1202 // missing sort in our code.
1203 fs::write(root.join("zebra.txt"), "z").expect("write zebra");
1204 fs::write(root.join("apple.txt"), "a").expect("write apple");
1205 fs::write(root.join("mango.txt"), "m").expect("write mango");
1206
1207 let tree = project_tree(root, 1, false);
1208 let lines: Vec<&str> = tree.lines().collect();
1209 let apple_pos = lines
1210 .iter()
1211 .position(|l| l.contains("apple.txt"))
1212 .expect("apple line");
1213 let mango_pos = lines
1214 .iter()
1215 .position(|l| l.contains("mango.txt"))
1216 .expect("mango line");
1217 let zebra_pos = lines
1218 .iter()
1219 .position(|l| l.contains("zebra.txt"))
1220 .expect("zebra line");
1221
1222 assert!(apple_pos < mango_pos);
1223 assert!(mango_pos < zebra_pos);
1224 }
1225
1226 #[test]
1227 fn project_tree_keeps_directory_before_its_children() {
1228 // Sorting siblings by full path is enough to preserve tree shape:
1229 // `"src" < "src/lib.rs"` because the shorter string compares less.
1230 let tmp = tempdir().expect("tempdir");
1231 let root = tmp.path();
1232 let src = root.join("src");
1233 fs::create_dir_all(&src).expect("mkdir src");
1234 fs::write(src.join("lib.rs"), "lib").expect("write lib");
1235 fs::write(src.join("main.rs"), "main").expect("write main");
1236
1237 let tree = project_tree(root, 2, false);
1238 let src_pos = tree.find("DIR: src").expect("src dir line");
1239 let lib_pos = tree.find("FILE: lib.rs").expect("lib file line");
1240 let main_pos = tree.find("FILE: main.rs").expect("main file line");
1241
1242 assert!(src_pos < lib_pos, "directory must precede its children");
1243 assert!(lib_pos < main_pos, "siblings sorted by name");
1244 }
1245
1246 #[test]
1247 fn project_tree_is_byte_stable_across_calls() {
1248 let tmp = tempdir().expect("tempdir");
1249 let root = tmp.path();
1250 fs::write(root.join("z.txt"), "z").expect("write");
1251 fs::write(root.join("a.txt"), "a").expect("write");
1252
1253 assert_eq!(project_tree(root, 1, false), project_tree(root, 1, false));
1254 }
1255
1256 #[test]
1257 #[cfg(unix)]
1258 fn project_mapping_does_not_follow_symlinked_key_files() {
1259 let tmp = tempdir().expect("tempdir");
1260 let root = tmp.path().join("workspace");
1261 let outside = tmp.path().join("outside");
1262 fs::create_dir_all(&root).expect("mkdir workspace");
1263 fs::create_dir_all(&outside).expect("mkdir outside");
1264 let outside_file = outside.join("Cargo.toml");
1265 fs::write(&outside_file, "[package]\nname = \"outside\"\n").expect("write outside");
1266 std::os::unix::fs::symlink(&outside_file, root.join("Cargo.toml")).expect("symlink");
1267
1268 assert_eq!(summarize_project(&root), "Unknown project type");
1269 assert!(!project_tree(&root, 1, false).contains("Cargo.toml"));
1270 }
1271
1272 #[test]
1273 fn summarize_project_sorts_key_files_in_fallback() {
1274 // When `summarize_project` can't classify a project type it falls
1275 // back to listing the discovered key files. That joined list must
1276 // be deterministic so the system prompt that embeds it doesn't
1277 // drift between runs on filesystems that emit readdir in a
1278 // non-alphabetical order.
1279 let tmp = tempdir().expect("tempdir");
1280 let root = tmp.path();
1281 // Use key files that don't trigger any of the type detectors
1282 // (Cargo.toml / package.json / requirements.txt) so the function
1283 // hits the `Project with key files: …` branch.
1284 fs::write(root.join("Makefile"), "all:").expect("write makefile");
1285 fs::write(root.join("README.md"), "# x").expect("write readme");
1286
1287 let summary = summarize_project(root);
1288 assert!(
1289 summary.starts_with("Project with key files: "),
1290 "expected fallback branch; got: {summary}"
1291 );
1292 let suffix = summary
1293 .strip_prefix("Project with key files: ")
1294 .expect("prefix");
1295 assert_eq!(suffix, "Makefile, README.md");
1296 }
1297
1298 // ===================================================================
1299 // open_url tests
1300 // ===================================================================
1301
1302 #[test]
1303 fn open_url_builds_platform_command_without_spawning() {
1304 let command = super::browser_open_command("https://example.com").expect("command");
1305
1306 #[cfg(target_os = "macos")]
1307 {
1308 assert_eq!(command.get_program(), "open");
1309 assert_eq!(
1310 command
1311 .get_args()
1312 .map(|arg| arg.to_string_lossy().into_owned())
1313 .collect::<Vec<_>>(),
1314 vec!["https://example.com"]
1315 );
1316 }
1317
1318 #[cfg(any(
1319 target_os = "netbsd",
1320 target_os = "freebsd",
1321 target_os = "openbsd",
1322 target_os = "dragonfly"
1323 ))]
1324 {
1325 assert_eq!(command.get_program(), "xdg-open");
1326 }
1327
1328 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1329 {
1330 assert_eq!(command.get_program(), "xdg-open");
1331 assert_eq!(
1332 command
1333 .get_args()
1334 .map(|arg| arg.to_string_lossy().into_owned())
1335 .collect::<Vec<_>>(),
1336 vec!["https://example.com"]
1337 );
1338 }
1339
1340 #[cfg(target_os = "windows")]
1341 {
1342 assert_eq!(command.get_program(), "cmd");
1343 assert_eq!(
1344 command
1345 .get_args()
1346 .map(|arg| arg.to_string_lossy().into_owned())
1347 .collect::<Vec<_>>(),
1348 vec!["/C", "start", "", "https://example.com"]
1349 );
1350 }
1351 }
1352
1353 #[test]
1354 fn open_url_rejects_empty_url_gracefully() {
1355 // An empty URL should fail with a clear error, not panic.
1356 let result = super::browser_open_command("");
1357 match result {
1358 Ok(_) => panic!("empty URL should not build an opener command"),
1359 Err(e) => {
1360 let msg = e.to_string();
1361 assert!(!msg.is_empty(), "error message must not be empty");
1362 assert!(msg.contains("empty"), "unexpected error message: {msg}");
1363 }
1364 }
1365 }
1366 }
1367
1367 lines RUST