返回 DeepSeek-TUI-2026
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
7 use crate::models::{ContentBlock, Message};
8 use anyhow::{Context, Result};
9 use ignore::WalkBuilder;
10 use serde_json::Value;
11
12 // === Project Mapping Helpers ===
13
14 /// Identify if a file is a "key" file for project identification.
15 #[must_use]
16 pub fn is_key_file(path: &Path) -> bool {
17 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
18 return false;
19 };
20
21 matches!(
22 file_name.to_lowercase().as_str(),
23 "cargo.toml"
24 | "package.json"
25 | "requirements.txt"
26 | "build.gradle"
27 | "pom.xml"
28 | "readme.md"
29 | "agents.md"
30 | "claude.md"
31 | "makefile"
32 | "dockerfile"
33 | "main.rs"
34 | "lib.rs"
35 | "index.js"
36 | "index.ts"
37 | "app.py"
38 )
39 }
40
41 /// Generate a high-level summary of the project based on key files.
42 ///
43 /// Output is byte-stable across calls: `WalkBuilder` doesn't sort siblings
44 /// (the OS readdir order leaks through), so the joined `key_files` list
45 /// would otherwise reorder run-to-run on filesystems that don't pre-sort.
46 /// Only matters when the workspace has no `AGENTS.md` / `CLAUDE.md`, since
47 /// the system prompt routes through `ProjectContext::as_system_block` first
48 /// and only falls back here when no project-context document exists.
49 #[must_use]
50 pub fn summarize_project(root: &Path) -> String {
51 let mut key_files = Vec::new();
52
53 let mut builder = WalkBuilder::new(root);
54 builder.hidden(false).follow_links(true).max_depth(Some(2));
55 let walker = builder.build();
56
57 for entry in walker {
58 let entry = match entry {
59 Ok(entry) => entry,
60 Err(_) => continue,
61 };
62 if is_key_file(entry.path())
63 && let Ok(rel) = entry.path().strip_prefix(root)
64 {
65 key_files.push(rel.to_string_lossy().to_string());
66 }
67 }
68
69 key_files.sort();
70
71 if key_files.is_empty() {
72 return "Unknown project type".to_string();
73 }
74
75 let mut types = Vec::new();
76 if key_files
77 .iter()
78 .any(|f| f.to_lowercase().contains("cargo.toml"))
79 {
80 types.push("Rust");
81 }
82 if key_files
83 .iter()
84 .any(|f| f.to_lowercase().contains("package.json"))
85 {
86 types.push("JavaScript/Node.js");
87 }
88 if key_files
89 .iter()
90 .any(|f| f.to_lowercase().contains("requirements.txt"))
91 {
92 types.push("Python");
93 }
94
95 if types.is_empty() {
96 format!("Project with key files: {}", key_files.join(", "))
97 } else {
98 format!("A {} project", types.join(" and "))
99 }
100 }
101
102 /// Generate a tree-like view of the project structure.
103 ///
104 /// Sibling order is fixed by sorting collected paths — the underlying
105 /// `WalkBuilder` follows the OS readdir order, which is non-deterministic
106 /// across filesystems. Sorting by full path preserves the tree shape (a
107 /// directory still precedes its children because `"src" < "src/lib.rs"`)
108 /// while making the rendered output byte-stable across runs.
109 #[must_use]
110 pub fn project_tree(root: &Path, max_depth: usize) -> String {
111 let mut entries: Vec<(PathBuf, bool)> = Vec::new();
112
113 let mut builder = WalkBuilder::new(root);
114 builder
115 .hidden(false)
116 .follow_links(true)
117 .max_depth(Some(max_depth + 1));
118
119 for entry in builder.build().flatten() {
120 let depth = entry.depth();
121 if depth == 0 || depth > max_depth {
122 continue;
123 }
124 let rel_path = entry
125 .path()
126 .strip_prefix(root)
127 .unwrap_or(entry.path())
128 .to_path_buf();
129 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
130 entries.push((rel_path, is_dir));
131 }
132
133 entries.sort_by(|a, b| a.0.cmp(&b.0));
134
135 let mut tree_lines = Vec::with_capacity(entries.len());
136 for (rel_path, is_dir) in entries {
137 let depth = rel_path.components().count();
138 let indent = " ".repeat(depth.saturating_sub(1));
139 let prefix = if is_dir { "DIR: " } else { "FILE: " };
140 tree_lines.push(format!(
141 "{}{}{}",
142 indent,
143 prefix,
144 rel_path.file_name().unwrap_or_default().to_string_lossy()
145 ));
146 }
147
148 tree_lines.join("\n")
149 }
150
151 // === Filesystem Helpers ===
152
153 /// Atomically write `contents` to `path` using a temporary file + fsync + rename.
154 ///
155 /// 1. Creates a `NamedTempFile` in the same directory as `path` (same filesystem).
156 /// 2. Writes `contents` to the temp file.
157 /// 3. Calls `sync_all()` on the temp file for durability.
158 /// 4. Atomically renames (persists) the temp file over `path`.
159 ///
160 /// On filesystems that support it (`ext4`, `apfs`, `ntfs`), the rename is
161 /// atomic — a concurrent reader sees either the old content or the new, never
162 /// a partial write. `sync_all` ensures the data is on stable storage before
163 /// the metadata change so an OS crash mid-rename doesn't lose data.
164 ///
165 /// # Errors
166 /// Returns `io::Error` if the parent directory cannot be determined, the temp
167 /// file cannot be created, the write fails, or the rename fails.
168 pub fn write_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> {
169 let parent = path.parent().ok_or_else(|| {
170 std::io::Error::new(
171 std::io::ErrorKind::InvalidInput,
172 format!("path has no parent directory: {}", path.display()),
173 )
174 })?;
175 // Use parent directory so the rename is on the same filesystem.
176 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
177 std::io::Write::write_all(&mut tmp, contents)?;
178 tmp.as_file().sync_all()?;
179 tmp.persist(path)?;
180 Ok(())
181 }
182
183 /// Open or create a file for appending at `path`, optionally syncing after
184 /// every write. Use this for append-only logs like `audit.log`.
185 ///
186 /// The returned `BufWriter<fs::File>` wraps the append handle. Call
187 /// `.flush()` followed by `.get_ref().sync_all()` after each batch.
188 pub fn open_append(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
189 if let Some(parent) = path.parent() {
190 std::fs::create_dir_all(parent)?;
191 }
192 let file = std::fs::OpenOptions::new()
193 .create(true)
194 .append(true)
195 .open(path)?;
196 Ok(std::io::BufWriter::new(file))
197 }
198
199 /// Flush a `BufWriter` wrapping a `File`, then `fsync` the underlying file.
200 pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> std::io::Result<()> {
201 writer.flush()?;
202 writer.get_ref().sync_all()
203 }
204
205 /// Spawn a tokio task with panic supervision.
206 ///
207 /// Wraps the future in `AssertUnwindSafe` + `catch_unwind`. On panic:
208 /// 1. Logs the panic with the task name and caller location via `tracing::error!`.
209 /// 2. Writes a crash dump to `~/.deepseek/crashes/<timestamp>-<name>.log`.
210 ///
211 /// The returned `JoinHandle` resolves to `()` — the panic is caught and
212 /// handled internally so the parent process stays alive.
213 pub fn spawn_supervised<F>(
214 name: &'static str,
215 location: &'static std::panic::Location<'static>,
216 future: F,
217 ) -> tokio::task::JoinHandle<()>
218 where
219 F: std::future::Future<Output = ()> + Send + 'static,
220 {
221 tokio::spawn(async move {
222 use futures_util::FutureExt;
223 let result = std::panic::AssertUnwindSafe(future).catch_unwind().await;
224 if let Err(panic_info) = result {
225 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
226 s.to_string()
227 } else if let Some(s) = panic_info.downcast_ref::<String>() {
228 s.clone()
229 } else {
230 "unknown panic".to_string()
231 };
232 tracing::error!(
233 target: "panic",
234 "Task '{name}' panicked at {}: {msg}",
235 location,
236 );
237 // Write crash dump (best-effort)
238 let _ = write_panic_dump(name, location, &msg);
239 }
240 })
241 }
242
243 /// Write a panic dump file to `~/.deepseek/crashes/`.
244 ///
245 /// Creates the directory if needed and writes a timestamped log
246 /// with the task name, caller location, and panic message.
247 /// Best-effort — failures are silently ignored.
248 fn write_panic_dump(
249 name: &str,
250 location: &std::panic::Location<'_>,
251 message: &str,
252 ) -> std::io::Result<()> {
253 let home = dirs::home_dir().ok_or_else(|| {
254 std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found")
255 })?;
256 let crash_dir = home.join(".deepseek").join("crashes");
257 write_panic_dump_to(&crash_dir, name, location, message)
258 }
259
260 fn write_panic_dump_to(
261 crash_dir: &Path,
262 name: &str,
263 location: &std::panic::Location<'_>,
264 message: &str,
265 ) -> std::io::Result<()> {
266 use chrono::Utc;
267 std::fs::create_dir_all(crash_dir)?;
268 let timestamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
269 let filename = format!("{timestamp}-{name}.log");
270 let path = crash_dir.join(&filename);
271 let contents =
272 format!("Task: {name}\nLocation: {location}\nTimestamp: {timestamp}\nPanic: {message}\n");
273 std::fs::write(&path, contents)?;
274 Ok(())
275 }
276
277 /// Fire-and-forget `spawn_blocking` with panic dump protection.
278 ///
279 /// In contrast to `spawn_supervised` (which wraps `tokio::spawn` for async
280 /// tasks), this helper wraps `tokio::task::spawn_blocking`. Use it when a
281 /// CPU-bound or blocking-I/O task must run off the async runtime and its
282 /// completion is *not* awaited — for example a post-turn disk snapshot or a
283 /// file-tree build polled later via a shared data structure. If the closure
284 /// panics, a crash dump is written to `~/.deepseek/crashes/` and the panic
285 /// is logged at ERROR level rather than being silently swallowed.
286 #[track_caller]
287 pub fn spawn_blocking_supervised<F>(name: &'static str, f: F) -> tokio::task::JoinHandle<()>
288 where
289 F: FnOnce() + Send + 'static,
290 {
291 let location = std::panic::Location::caller();
292 tokio::task::spawn_blocking(move || {
293 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
294 if let Err(panic_info) = result {
295 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
296 s.to_string()
297 } else if let Some(s) = panic_info.downcast_ref::<String>() {
298 s.clone()
299 } else {
300 "unknown panic".to_string()
301 };
302 tracing::error!(
303 target: "panic",
304 "Blocking task '{name}' panicked at {location}: {msg}",
305 );
306 let _ = write_panic_dump(name, location, &msg);
307 }
308 })
309 }
310
311 #[allow(dead_code)]
312 pub fn ensure_dir(path: &Path) -> Result<()> {
313 fs::create_dir_all(path)
314 .with_context(|| format!("Failed to create directory: {}", path.display()))
315 }
316
317 /// Render JSON with pretty formatting, falling back to a compact string on error.
318 #[must_use]
319 #[allow(dead_code)]
320 pub fn pretty_json(value: &Value) -> String {
321 serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
322 }
323
324 /// Truncate a string to a maximum length, adding an ellipsis if truncated.
325 ///
326 /// Uses char boundaries to avoid panicking on multi-byte UTF-8 characters.
327 #[must_use]
328 pub fn truncate_with_ellipsis(s: &str, max_len: usize, ellipsis: &str) -> String {
329 if s.len() <= max_len {
330 return s.to_string();
331 }
332 let budget = max_len.saturating_sub(ellipsis.len());
333 // Find the last char boundary that fits within the byte budget.
334 let safe_end = s
335 .char_indices()
336 .map(|(i, _)| i)
337 .take_while(|&i| i <= budget)
338 .last()
339 .unwrap_or(0);
340 format!("{}{}", &s[..safe_end], ellipsis)
341 }
342
343 /// Percent-encode a string for use in URL query parameters.
344 ///
345 /// Encodes all characters except unreserved characters (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`).
346 /// Spaces are encoded as `+`.
347 #[must_use]
348 pub fn url_encode(input: &str) -> String {
349 let mut encoded = String::new();
350 for ch in input.bytes() {
351 match ch {
352 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
353 encoded.push(ch as char)
354 }
355 b' ' => encoded.push('+'),
356 _ => encoded.push_str(&format!("%{ch:02X}")),
357 }
358 }
359 encoded
360 }
361
362 /// Render a path for **user-facing display** with the home directory
363 /// contracted to `~`. Use this in the TUI, doctor/setup stdout, and any
364 /// other place a viewer might see the output (screenshot, video,
365 /// pasted-into-issue help). On macOS/Linux the absolute path
366 /// `/Users/<name>/...` or `/home/<name>/...` reveals the OS account name,
367 /// which is often the same as a public handle — undesirable for users
368 /// who share their terminal.
369 ///
370 /// **Do not use** this for paths that get persisted (sessions, audit log)
371 /// or sent to the LLM provider — those want full fidelity so they
372 /// resolve correctly across processes.
373 #[must_use]
374 pub fn display_path(path: &Path) -> String {
375 display_path_with_home(path, dirs::home_dir().as_deref())
376 }
377
378 /// Like [`display_path`] but takes an explicit home directory instead of
379 /// reading `$HOME` / `dirs::home_dir()`. Used in tests and anywhere the
380 /// caller already has the home path available.
381 ///
382 /// The home-relative suffix is rejoined with the platform separator
383 /// (`\` on Windows, `/` elsewhere) by walking the path's components, so
384 /// inputs that carried foreign separators don't leak through.
385 #[must_use]
386 pub fn display_path_with_home(path: &Path, home: Option<&Path>) -> String {
387 let Some(home) = home else {
388 return path.display().to_string();
389 };
390 if let Ok(rest) = path.strip_prefix(home) {
391 if rest.as_os_str().is_empty() {
392 return "~".to_string();
393 }
394 let sep = std::path::MAIN_SEPARATOR_STR;
395 let mut out = String::from("~");
396 for component in rest.components() {
397 out.push_str(sep);
398 out.push_str(&component.as_os_str().to_string_lossy());
399 }
400 return out;
401 }
402 path.display().to_string()
403 }
404
405 /// Check whether the system locale is Chinese (zh-*).
406 ///
407 /// Reads `LC_ALL`, `LC_MESSAGES`, and `LANG` environment variables.
408 /// Used by the first-run flow to suggest `DeepseekCN` as the default
409 /// provider for users in China.
410 #[must_use]
411 pub fn is_chinese_system_locale() -> bool {
412 for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
413 if let Ok(value) = std::env::var(key) {
414 let normalized = value.split('.').next().unwrap_or(&value).replace('_', "-");
415 if normalized.to_ascii_lowercase().starts_with("zh") {
416 return true;
417 }
418 }
419 }
420 false
421 }
422
423 /// Estimate the total character count across message content blocks.
424 #[must_use]
425 pub fn estimate_message_chars(messages: &[Message]) -> usize {
426 let mut total = 0;
427 for msg in messages {
428 for block in &msg.content {
429 match block {
430 ContentBlock::Text { text, .. } => total += text.len(),
431 ContentBlock::Thinking { thinking } => total += thinking.len(),
432 ContentBlock::ToolUse { input, .. } => total += input.to_string().len(),
433 ContentBlock::ToolResult { content, .. } => total += content.len(),
434 ContentBlock::ServerToolUse { .. }
435 | ContentBlock::ToolSearchToolResult { .. }
436 | ContentBlock::CodeExecutionToolResult { .. } => {}
437 }
438 }
439 }
440 total
441 }
442
443 // Tests use `display_path_with_home` so they never mutate the global `HOME`
444 // env var. Mutating `HOME` via `std::env::set_var` is not thread-safe; Cargo
445 // runs tests in parallel by default and CI runners are multi-core, so any test
446 // that stomps `HOME` will race with tests that *read* it. Using the injected
447 // helper avoids the race entirely and makes the tests portable to Windows
448 // without additional platform scaffolding.
449 #[cfg(test)]
450 mod tests {
451 use super::display_path_with_home;
452 use std::path::PathBuf;
453
454 fn home(s: &str) -> Option<PathBuf> {
455 Some(PathBuf::from(s))
456 }
457
458 #[test]
459 fn display_path_contracts_home_prefix() {
460 let h = home("/Users/alice");
461 assert_eq!(
462 display_path_with_home(&PathBuf::from("/Users/alice/projects/foo"), h.as_deref()),
463 format!(
464 "~{}projects{}foo",
465 std::path::MAIN_SEPARATOR,
466 std::path::MAIN_SEPARATOR
467 ),
468 );
469 }
470
471 #[test]
472 fn display_path_returns_bare_tilde_for_home_itself() {
473 let h = home("/Users/alice");
474 assert_eq!(
475 display_path_with_home(&PathBuf::from("/Users/alice"), h.as_deref()),
476 "~"
477 );
478 }
479
480 #[test]
481 fn display_path_leaves_unrelated_paths_alone() {
482 let h = home("/Users/alice");
483 // Different user — must not get rewritten or share the tilde.
484 assert_eq!(
485 display_path_with_home(&PathBuf::from("/Users/bob/Code"), h.as_deref()),
486 "/Users/bob/Code".to_string()
487 );
488 // System path must stay absolute.
489 assert_eq!(
490 display_path_with_home(&PathBuf::from("/etc/hosts"), h.as_deref()),
491 "/etc/hosts"
492 );
493 }
494
495 #[test]
496 fn display_path_does_not_match_username_prefix() {
497 // Regression guard: a directory named like the user's home
498 // *prefix* but not under it must not get rewritten.
499 let h = home("/Users/alice");
500 assert_eq!(
501 display_path_with_home(&PathBuf::from("/Users/alice2/work"), h.as_deref()),
502 "/Users/alice2/work"
503 );
504 }
505
506 #[test]
507 fn display_path_with_no_home_returns_full_path() {
508 assert_eq!(
509 display_path_with_home(&PathBuf::from("/some/path"), None),
510 "/some/path"
511 );
512 }
513 }
514
515 #[cfg(test)]
516 mod atomic_write_tests {
517 use super::*;
518 use std::fs;
519 use tempfile::tempdir;
520
521 #[test]
522 fn write_atomic_writes_content() {
523 let tmp = tempdir().expect("tempdir");
524 let path = tmp.path().join("test.json");
525 let content = b"hello atomic world";
526
527 write_atomic(&path, content).expect("write_atomic");
528 assert!(path.exists());
529 let read = fs::read_to_string(&path).expect("read");
530 assert_eq!(read.as_bytes(), content);
531 }
532
533 #[test]
534 fn write_atomic_replaces_existing_file() {
535 let tmp = tempdir().expect("tempdir");
536 let path = tmp.path().join("existing.json");
537 fs::write(&path, b"old content").expect("write old");
538 write_atomic(&path, b"new content").expect("write_atomic");
539 let read = fs::read_to_string(&path).expect("read");
540 assert_eq!(read, "new content");
541 }
542
543 #[test]
544 fn write_atomic_no_temp_left_behind_on_success() {
545 let tmp = tempdir().expect("tempdir");
546 let path = tmp.path().join("clean.json");
547 write_atomic(&path, b"clean").expect("write_atomic");
548 // List files in dir — there should be no .tmp files left
549 let entries: Vec<_> = fs::read_dir(tmp.path())
550 .expect("read_dir")
551 .filter_map(|e| e.ok())
552 .collect();
553 let tmp_files: Vec<_> = entries
554 .iter()
555 .filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with('.')))
556 .collect();
557 assert!(
558 tmp_files.is_empty(),
559 "temp files left behind: {tmp_files:?}"
560 );
561 }
562
563 #[test]
564 fn flush_and_sync_writes_and_syncs() {
565 let tmp = tempdir().expect("tempdir");
566 let path = tmp.path().join("append.log");
567 {
568 let mut writer = open_append(&path).expect("open_append");
569 writeln!(writer, "line 1").expect("write");
570 flush_and_sync(&mut writer).expect("flush_and_sync");
571 writeln!(writer, "line 2").expect("write");
572 flush_and_sync(&mut writer).expect("flush_and_sync");
573 }
574 let content = fs::read_to_string(&path).expect("read");
575 assert_eq!(content, "line 1\nline 2\n");
576 }
577 }
578
579 #[cfg(test)]
580 mod spawn_supervised_tests {
581 use super::*;
582 use std::sync::Arc;
583 use std::sync::atomic::{AtomicBool, Ordering};
584
585 /// A spawned task that panics does not propagate the panic to the
586 /// parent task — `spawn_supervised` catches it. Verified in isolation
587 /// from the on-disk crash-dump path so the test is portable across
588 /// macOS / Linux / Windows (where `dirs::home_dir()` reads
589 /// `USERPROFILE`, not `HOME`, so env-mutation tricks don't redirect
590 /// the dump on Windows).
591 #[tokio::test]
592 async fn panicking_task_does_not_propagate_to_parent() {
593 let parent_alive = Arc::new(AtomicBool::new(false));
594 let parent_alive_clone = parent_alive.clone();
595
596 let handle = spawn_supervised(
597 "panic-test-fixture",
598 std::panic::Location::caller(),
599 async move {
600 parent_alive_clone.store(true, Ordering::SeqCst);
601 panic!("deliberate panic for catch-unwind test");
602 },
603 );
604
605 let result = handle.await;
606 assert!(
607 result.is_ok(),
608 "spawn_supervised must convert panic to a normal completion"
609 );
610 assert!(
611 parent_alive.load(Ordering::SeqCst),
612 "fixture task must have run before panicking"
613 );
614 }
615
616 #[tokio::test]
617 async fn panicking_blocking_task_does_not_propagate_to_parent() {
618 let parent_alive = Arc::new(AtomicBool::new(false));
619 let parent_alive_clone = parent_alive.clone();
620
621 let handle = spawn_blocking_supervised("blocking-panic-test-fixture", move || {
622 parent_alive_clone.store(true, Ordering::SeqCst);
623 panic!("deliberate panic for spawn_blocking catch-unwind test");
624 });
625
626 let result = handle.await;
627 assert!(
628 result.is_ok(),
629 "spawn_blocking_supervised must convert panic to a normal completion"
630 );
631 assert!(
632 parent_alive.load(Ordering::SeqCst),
633 "fixture blocking task must have run before panicking"
634 );
635 }
636
637 /// `write_panic_dump_to` writes a properly-formatted crash log into
638 /// the supplied directory. Tested separately from `spawn_supervised`
639 /// because env-mutation redirection of `dirs::home_dir()` doesn't
640 /// work on Windows.
641 #[test]
642 fn write_panic_dump_writes_named_log() {
643 let tmp = tempfile::tempdir().expect("tempdir");
644 let crash_dir = tmp.path().join("crashes");
645 let location = std::panic::Location::caller();
646 write_panic_dump_to(&crash_dir, "panic-fixture", location, "boom").expect("write dump");
647
648 let entries: Vec<_> = std::fs::read_dir(&crash_dir)
649 .expect("crashes dir exists")
650 .flatten()
651 .collect();
652 assert_eq!(entries.len(), 1, "exactly one crash dump expected");
653 let dump = std::fs::read_to_string(entries[0].path()).expect("read dump");
654 assert!(
655 dump.contains("panic-fixture"),
656 "dump must include the task name; got: {dump}"
657 );
658 assert!(
659 dump.contains("boom"),
660 "dump must include the panic message; got: {dump}"
661 );
662 }
663 }
664
665 #[cfg(test)]
666 mod project_mapping_tests {
667 use super::{project_tree, summarize_project};
668 use std::fs;
669 use tempfile::tempdir;
670
671 #[test]
672 fn project_tree_sorts_siblings_alphabetically() {
673 // Cross-platform readdir doesn't guarantee alphabetical order — on
674 // ext4 with htree it's hash order, on APFS it's roughly insertion
675 // order, on ZFS it's storage-class dependent. The system prompt
676 // embeds this string in the cached prefix when a workspace has no
677 // AGENTS.md / CLAUDE.md, so the function has to be byte-stable
678 // across runs regardless of host filesystem.
679 let tmp = tempdir().expect("tempdir");
680 let root = tmp.path();
681 // Create files in a deliberately scrambled order to make the
682 // hosting filesystem's pre-sort (if any) less likely to mask a
683 // missing sort in our code.
684 fs::write(root.join("zebra.txt"), "z").expect("write zebra");
685 fs::write(root.join("apple.txt"), "a").expect("write apple");
686 fs::write(root.join("mango.txt"), "m").expect("write mango");
687
688 let tree = project_tree(root, 1);
689 let lines: Vec<&str> = tree.lines().collect();
690 let apple_pos = lines
691 .iter()
692 .position(|l| l.contains("apple.txt"))
693 .expect("apple line");
694 let mango_pos = lines
695 .iter()
696 .position(|l| l.contains("mango.txt"))
697 .expect("mango line");
698 let zebra_pos = lines
699 .iter()
700 .position(|l| l.contains("zebra.txt"))
701 .expect("zebra line");
702
703 assert!(apple_pos < mango_pos);
704 assert!(mango_pos < zebra_pos);
705 }
706
707 #[test]
708 fn project_tree_keeps_directory_before_its_children() {
709 // Sorting siblings by full path is enough to preserve tree shape:
710 // `"src" < "src/lib.rs"` because the shorter string compares less.
711 let tmp = tempdir().expect("tempdir");
712 let root = tmp.path();
713 let src = root.join("src");
714 fs::create_dir_all(&src).expect("mkdir src");
715 fs::write(src.join("lib.rs"), "lib").expect("write lib");
716 fs::write(src.join("main.rs"), "main").expect("write main");
717
718 let tree = project_tree(root, 2);
719 let src_pos = tree.find("DIR: src").expect("src dir line");
720 let lib_pos = tree.find("FILE: lib.rs").expect("lib file line");
721 let main_pos = tree.find("FILE: main.rs").expect("main file line");
722
723 assert!(src_pos < lib_pos, "directory must precede its children");
724 assert!(lib_pos < main_pos, "siblings sorted by name");
725 }
726
727 #[test]
728 fn project_tree_is_byte_stable_across_calls() {
729 let tmp = tempdir().expect("tempdir");
730 let root = tmp.path();
731 fs::write(root.join("z.txt"), "z").expect("write");
732 fs::write(root.join("a.txt"), "a").expect("write");
733
734 assert_eq!(project_tree(root, 1), project_tree(root, 1));
735 }
736
737 #[test]
738 fn summarize_project_sorts_key_files_in_fallback() {
739 // When `summarize_project` can't classify a project type it falls
740 // back to listing the discovered key files. That joined list must
741 // be deterministic so the system prompt that embeds it doesn't
742 // drift between runs on filesystems that emit readdir in a
743 // non-alphabetical order.
744 let tmp = tempdir().expect("tempdir");
745 let root = tmp.path();
746 // Use key files that don't trigger any of the type detectors
747 // (Cargo.toml / package.json / requirements.txt) so the function
748 // hits the `Project with key files: …` branch.
749 fs::write(root.join("Makefile"), "all:").expect("write makefile");
750 fs::write(root.join("README.md"), "# x").expect("write readme");
751
752 let summary = summarize_project(root);
753 assert!(
754 summary.starts_with("Project with key files: "),
755 "expected fallback branch; got: {summary}"
756 );
757 let suffix = summary
758 .strip_prefix("Project with key files: ")
759 .expect("prefix");
760 assert_eq!(suffix, "Makefile, README.md");
761 }
762 }
763
763 lines RUST