Skip to main content

st/formatters/
projects.rs

1// Projects Discovery Mode - Fast project scanner for AI context
2// Finds all README.md files and creates condensed project summaries
3
4use anyhow::Result;
5use rayon::prelude::*;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use walkdir::{DirEntry, WalkDir};
10
11use crate::formatters::Formatter;
12use crate::scanner::{FileNode, TreeStats};
13
14#[derive(Debug, Clone)]
15pub struct ProjectInfo {
16    pub path: PathBuf,
17    pub name: String,
18    pub project_type: ProjectType,
19    pub summary: String, // Condensed summary from README
20    pub size: u64,       // Total project size
21    pub file_count: usize,
22    pub created: u64, // Creation timestamp
23    pub last_modified: u64,
24    pub last_accessed: u64,        // Last access time
25    pub dependencies: Vec<String>, // Key dependencies detected
26    pub hex_signature: String,     // HEX mode-like signature
27    pub git_info: Option<GitInfo>, // Git repository information
28}
29
30#[derive(Debug, Clone)]
31pub struct GitInfo {
32    pub branch: String,
33    pub commit: String,         // Short commit hash
34    pub commit_message: String, // First line of commit message
35    pub is_dirty: bool,         // Has uncommitted changes
36    pub ahead: usize,           // Commits ahead of upstream
37    pub behind: usize,          // Commits behind upstream
38    pub last_commit_date: u64,  // Timestamp of last commit
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub enum ProjectType {
43    Rust,       // Cargo.toml
44    NodeJs,     // package.json
45    Python,     // requirements.txt, pyproject.toml, setup.py
46    Go,         // go.mod
47    Java,       // pom.xml, build.gradle
48    DotNet,     // *.csproj, *.sln
49    Ruby,       // Gemfile
50    Docker,     // Dockerfile
51    Kubernetes, // k8s yaml files
52    Monorepo,   // Multiple project types
53    Unknown,
54}
55
56pub struct ProjectsFormatter {
57    max_depth: Option<usize>,
58    min_project_size: u64, // Skip tiny projects
59    show_dependencies: bool,
60    condensed_mode: bool, // Ultra-condensed for AI
61}
62
63impl Default for ProjectsFormatter {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl ProjectsFormatter {
70    pub fn new() -> Self {
71        Self {
72            max_depth: Some(8),     // Don't go too deep by default
73            min_project_size: 1024, // Skip projects < 1KB
74            show_dependencies: true,
75            condensed_mode: true,
76        }
77    }
78
79    /// Scan directory for all projects
80    pub fn scan_projects(&self, root: &Path) -> Result<Vec<ProjectInfo>> {
81        let projects = Arc::new(Mutex::new(Vec::new()));
82        let seen_paths = Arc::new(Mutex::new(std::collections::HashSet::new()));
83        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
84
85        // First pass: Find projects with README.md
86        let walker = WalkDir::new(&root)
87            .max_depth(self.max_depth.unwrap_or(10))
88            .follow_links(false)
89            .into_iter()
90            .filter_map(|e| e.ok())
91            .collect::<Vec<_>>();
92
93        walker.par_iter().for_each(|entry| {
94            // Check for README.md files
95            if self.is_readme(entry) {
96                let project_path = entry.path().parent().unwrap();
97                if seen_paths
98                    .lock()
99                    .unwrap()
100                    .insert(project_path.to_path_buf())
101                {
102                    if let Ok(project) = self.analyze_project(project_path) {
103                        if project.size >= self.min_project_size {
104                            projects.lock().unwrap().push(project);
105                        }
106                    }
107                }
108            }
109
110            // Also check for project marker files (even without README)
111            if entry.file_type().is_file() {
112                let filename = entry.file_name().to_str().unwrap_or("");
113                let is_project_marker = matches!(
114                    filename,
115                    "Cargo.toml"
116                        | "package.json"
117                        | "go.mod"
118                        | "pom.xml"
119                        | "build.gradle"
120                        | "Gemfile"
121                        | "requirements.txt"
122                        | "pyproject.toml"
123                        | "Dockerfile"
124                        | ".gitmodules"
125                        | "setup.py"
126                        | "Makefile"
127                        | "CMakeLists.txt"
128                        | "configure.ac"
129                        | "Rakefile"
130                        | "build.xml"
131                        | "build.gradle.kts"
132                        | "build.sbt"
133                        | "build.sh"
134                        | "build.ps1"
135                        | "build.bat"
136                        | "CMakeCache.txt"
137                        | "CMakeLists.txt.user"
138                        | "CMakeLists.txt.in"
139                        | "CMakeLists.txt.cmake"
140                        | ".gitignore"
141                        | ".dockerignore"
142                        | "docker-compose.yml"
143                        | "kustomization.yaml"
144                        | "config.yaml"
145                        | "CLAUDE.md"
146                        
147
148
149                    );
150
151                if is_project_marker {
152                    let project_path = entry.path().parent().unwrap();
153
154                    // Check if we haven't seen this project yet
155                    if seen_paths
156                        .lock()
157                        .unwrap()
158                        .insert(project_path.to_path_buf())
159                    {
160                        // Look for README in parent directory if not found
161                        let readme_path = if !project_path.join("README.md").exists() {
162                            // Check parent directory
163                            project_path
164                                .parent()
165                                .and_then(|p| {
166                                    if p.join("README.md").exists() {
167                                        Some(p)
168                                    } else {
169                                        None
170                                    }
171                                })
172                                .unwrap_or(project_path)
173                        } else {
174                            project_path
175                        };
176
177                        if let Ok(project) =
178                            self.analyze_project_with_readme_path(project_path, readme_path)
179                        {
180                            if project.size >= self.min_project_size {
181                                projects.lock().unwrap().push(project);
182                            }
183                        }
184                    }
185                }
186            }
187        });
188
189        let mut result = projects.lock().unwrap().clone();
190        result.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); // Most recent first
191        Ok(result)
192    }
193
194    fn is_readme(&self, entry: &DirEntry) -> bool {
195        entry.file_type().is_file()
196            && entry
197                .file_name()
198                .to_str()
199                .map(|s| s.eq_ignore_ascii_case("README.md"))
200                .unwrap_or(false)
201    }
202
203    /// Analyze a project directory
204    fn analyze_project(&self, project_path: &Path) -> Result<ProjectInfo> {
205        self.analyze_project_with_readme_path(project_path, project_path)
206    }
207
208    /// Analyze project with potentially different README location
209    fn analyze_project_with_readme_path(
210        &self,
211        project_path: &Path,
212        readme_path: &Path,
213    ) -> Result<ProjectInfo> {
214        let mut name = project_path
215            .file_name()
216            .and_then(|n| n.to_str())
217            .unwrap_or("unknown")
218            .to_string();
219
220        // Check if this is a git submodule
221        let is_submodule = project_path.join(".git").exists()
222            && project_path
223                .parent()
224                .map(|p| p.join(".gitmodules").exists())
225                .unwrap_or(false);
226
227        if is_submodule {
228            name = format!("πŸ“Ž{}", name); // Indicate submodule
229        }
230
231        let mut project_type = self.detect_project_type(project_path);
232
233        // If no type detected, check parent directory
234        if project_type == ProjectType::Unknown && project_path != readme_path {
235            project_type = self.detect_project_type(readme_path);
236        }
237
238        let summary = self.extract_summary(readme_path)?;
239        let (size, file_count, created, last_modified, last_accessed) =
240            self.get_project_stats(project_path)?;
241        let mut dependencies = self.detect_dependencies(project_path, &project_type);
242
243        // Check for git submodules as dependencies
244        if let Ok(submodules) = self.detect_git_submodules(project_path) {
245            for submodule in submodules {
246                dependencies.push(format!("πŸ“Ž{}", submodule));
247            }
248        }
249
250        // Get git information if available
251        let git_info = self.get_git_info(project_path);
252
253        // Generate HEX-like signature
254        let hex_signature = self.generate_hex_signature(&name, &project_type, size);
255
256        Ok(ProjectInfo {
257            path: project_path.to_path_buf(),
258            name,
259            project_type,
260            summary,
261            size,
262            file_count,
263            created,
264            last_modified,
265            last_accessed,
266            dependencies,
267            hex_signature,
268            git_info,
269        })
270    }
271
272    /// Detect git submodules in project
273    fn detect_git_submodules(&self, path: &Path) -> Result<Vec<String>> {
274        let gitmodules_path = path.join(".gitmodules");
275        let mut submodules = Vec::new();
276
277        if gitmodules_path.exists() {
278            let content = fs::read_to_string(&gitmodules_path)?;
279            for line in content.lines() {
280                if line.trim().starts_with("path = ") {
281                    if let Some(path) = line.split('=').nth(1) {
282                        submodules.push(path.trim().to_string());
283                    }
284                }
285            }
286        }
287
288        Ok(submodules)
289    }
290
291    /// Detect project type based on marker files
292    fn detect_project_type(&self, path: &Path) -> ProjectType {
293        let markers = vec![
294            ("Cargo.toml", ProjectType::Rust),
295            ("package.json", ProjectType::NodeJs),
296            ("requirements.txt", ProjectType::Python),
297            ("pyproject.toml", ProjectType::Python),
298            ("setup.py", ProjectType::Python),
299            ("go.mod", ProjectType::Go),
300            ("pom.xml", ProjectType::Java),
301            ("build.gradle", ProjectType::Java),
302            ("Gemfile", ProjectType::Ruby),
303            ("Dockerfile", ProjectType::Docker),
304        ];
305
306        let mut detected_types = Vec::new();
307
308        for (marker, proj_type) in markers {
309            if path.join(marker).exists() {
310                detected_types.push(proj_type);
311            }
312        }
313
314        // Check for .csproj or .sln files
315        if let Ok(entries) = fs::read_dir(path) {
316            for entry in entries.flatten() {
317                if let Some(ext) = entry.path().extension() {
318                    if ext == "csproj" || ext == "sln" {
319                        detected_types.push(ProjectType::DotNet);
320                        break;
321                    }
322                }
323            }
324        }
325
326        match detected_types.len() {
327            0 => ProjectType::Unknown,
328            1 => detected_types[0].clone(),
329            _ => ProjectType::Monorepo,
330        }
331    }
332
333    /// Extract and condense summary from README.md
334    fn extract_summary(&self, project_path: &Path) -> Result<String> {
335        let readme_path = project_path.join("README.md");
336        if !readme_path.exists() {
337            return Ok(String::new());
338        }
339
340        let content = fs::read_to_string(&readme_path)?;
341
342        // Get first paragraph or description
343        let summary = self.extract_description(&content);
344
345        if self.condensed_mode {
346            Ok(self.condense_text(&summary))
347        } else {
348            Ok(summary)
349        }
350    }
351
352    /// Extract description from README content
353    fn extract_description(&self, content: &str) -> String {
354        let lines: Vec<&str> = content.lines().collect();
355        let mut description = String::new();
356        let mut found_header = false;
357        let mut in_code_block = false;
358        let mut consecutive_content_lines = 0;
359
360        for line in lines.iter().take(50) {
361            // Look through more lines
362            let trimmed = line.trim();
363
364            // Track code blocks
365            if trimmed.starts_with("```") {
366                in_code_block = !in_code_block;
367                continue;
368            }
369
370            if in_code_block {
371                continue;
372            }
373
374            // Skip various non-content patterns
375            if trimmed.is_empty() {
376                // Empty line - if we have content, check if we should stop
377                if consecutive_content_lines >= 2 {
378                    break; // We've found enough content
379                }
380                continue;
381            }
382
383            // Skip headers but note we've seen them
384            if trimmed.starts_with('#') {
385                found_header = true;
386                consecutive_content_lines = 0;
387                continue;
388            }
389
390            // Skip all the badge/shield/image/link-only lines
391            if trimmed.starts_with("![") ||       // Images
392               trimmed.starts_with("[![") ||      // Clickable badges
393               trimmed.starts_with("<!--") ||     // HTML comments
394               trimmed.starts_with("<") ||        // HTML tags
395               trimmed.starts_with(">") ||        // Blockquotes (often used for notes)
396               trimmed.starts_with("[!") ||       // GitHub alerts
397               trimmed.starts_with("- [") ||      // TOC entries
398               trimmed.starts_with("* [") ||      // TOC entries
399               trimmed.starts_with("+ [") ||      // TOC entries
400               trimmed.starts_with("|") ||        // Table rows
401               trimmed.starts_with("---") ||     // Horizontal rules
402               trimmed.starts_with("===") ||     // Alternative headers
403               (trimmed.starts_with("[") && trimmed.ends_with(")") &&
404                (trimmed.contains("shields.io") || trimmed.contains("badge")))
405            {
406                continue;
407            }
408
409            // Check if this line is mostly links (common in READMEs)
410            let link_count = trimmed.matches("](").count();
411            let word_count = trimmed.split_whitespace().count();
412            if word_count > 0 && link_count > 0 {
413                let link_ratio = link_count as f32 / word_count as f32;
414                if link_ratio > 0.4 {
415                    // More than 40% links
416                    continue;
417                }
418            }
419
420            // Skip very short lines that might just be fragments
421            if trimmed.len() < 15 && !found_header {
422                continue;
423            }
424
425            // Skip lines that are just URLs
426            if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
427                continue;
428            }
429
430            // This looks like real content!
431            if !description.is_empty() {
432                description.push(' ');
433            }
434
435            // Clean up any markdown formatting as we add it
436            let cleaned = trimmed
437                .replace("**", "") // Bold
438                .replace("__", "") // Bold alt
439                .replace("~~", "") // Strikethrough
440                .replace("`", ""); // Inline code
441
442            description.push_str(&cleaned);
443            consecutive_content_lines += 1;
444
445            // Stop after we have enough content
446            if description.len() > 200 || consecutive_content_lines >= 3 {
447                break;
448            }
449        }
450
451        // Final cleanup - remove any leftover markdown artifacts
452        let mut final_desc = description.trim().to_string();
453
454        // Remove emoji shortcodes that might remain
455        if final_desc.contains(':') {
456            final_desc = final_desc
457                .split_whitespace()
458                .filter(|word| !word.starts_with(':') || !word.ends_with(':'))
459                .collect::<Vec<_>>()
460                .join(" ");
461        }
462
463        // Truncate if still too long
464        if final_desc.len() > 250 {
465            let mut truncated = String::new();
466            for (char_count, ch) in final_desc.chars().enumerate() {
467                if char_count >= 247 {
468                    break;
469                }
470                truncated.push(ch);
471            }
472
473            format!("{}...", truncated)
474        } else {
475            final_desc
476        }
477    }
478
479    /// Condense text by removing vowels, stopwords, spaces
480    fn condense_text(&self, text: &str) -> String {
481        // Common stopwords to remove
482        let stopwords = vec![
483            "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with",
484            "by", "from", "up", "about", "into", "through", "during", "is", "are", "was", "were",
485            "be", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would",
486            "should", "could", "may", "might", "this", "that", "these", "those", "it", "its",
487            "which", "what",
488        ];
489
490        let words: Vec<&str> = text.split_whitespace().collect();
491        let mut condensed = Vec::new();
492
493        for word in words {
494            let lower = word.to_lowercase();
495
496            // Skip stopwords
497            if stopwords.contains(&lower.as_str()) {
498                continue;
499            }
500
501            // Remove vowels from words > 3 chars
502            let condensed_word = if word.len() > 3 {
503                word.chars()
504                    .filter(|c| !"aeiouAEIOU".contains(*c))
505                    .collect::<String>()
506            } else {
507                word.to_string()
508            };
509
510            if !condensed_word.is_empty() {
511                condensed.push(condensed_word);
512            }
513        }
514
515        // Join with minimal separator
516        condensed.join(".")
517    }
518
519    /// Get project statistics with timestamps
520    fn get_project_stats(&self, path: &Path) -> Result<(u64, usize, u64, u64, u64)> {
521        let mut total_size = 0u64;
522        let mut file_count = 0usize;
523        let mut last_modified = 0u64;
524        let mut created = u64::MAX;
525        let mut last_accessed = 0u64;
526
527        // Quick scan - don't recurse into node_modules, target, etc.
528        let ignored_dirs = [
529            "node_modules",
530            "target",
531            ".git",
532            "dist",
533            "build",
534            "__pycache__",
535        ];
536
537        // Get project directory metadata for creation time
538        if let Ok(dir_metadata) = fs::metadata(path) {
539            #[cfg(unix)]
540            {
541                use std::os::unix::fs::MetadataExt;
542                created = dir_metadata.ctime() as u64;
543                last_accessed = dir_metadata.atime() as u64;
544            }
545            #[cfg(not(unix))]
546            {
547                if let Ok(created_time) = dir_metadata.created() {
548                    if let Ok(duration) = created_time.duration_since(std::time::UNIX_EPOCH) {
549                        created = duration.as_secs();
550                    }
551                }
552                if let Ok(accessed_time) = dir_metadata.accessed() {
553                    if let Ok(duration) = accessed_time.duration_since(std::time::UNIX_EPOCH) {
554                        last_accessed = duration.as_secs();
555                    }
556                }
557            }
558        }
559
560        for entry in WalkDir::new(path)
561            .max_depth(3) // Don't go too deep for stats
562            .into_iter()
563            .filter_entry(|e| {
564                !e.file_name()
565                    .to_str()
566                    .map(|s| ignored_dirs.contains(&s))
567                    .unwrap_or(false)
568            })
569            .filter_map(|e| e.ok())
570        {
571            if entry.file_type().is_file() {
572                file_count += 1;
573                if let Ok(metadata) = entry.metadata() {
574                    total_size += metadata.len();
575                    if let Ok(modified) = metadata.modified() {
576                        if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
577                            last_modified = last_modified.max(duration.as_secs());
578                        }
579                    }
580                }
581            }
582        }
583
584        Ok((
585            total_size,
586            file_count,
587            created,
588            last_modified,
589            last_accessed,
590        ))
591    }
592
593    /// Get git information using command-line git
594    fn get_git_info(&self, path: &Path) -> Option<GitInfo> {
595        use std::process::Command;
596
597        // Check if this is a git repository
598        if !path.join(".git").exists() {
599            return None;
600        }
601
602        // Get current branch
603        let branch = Command::new("git")
604            .arg("branch")
605            .arg("--show-current")
606            .current_dir(path)
607            .output()
608            .ok()
609            .and_then(|output| String::from_utf8(output.stdout).ok())
610            .map(|s| s.trim().to_string())
611            .unwrap_or_else(|| "unknown".to_string());
612
613        // Get current commit hash (short)
614        let commit = Command::new("git")
615            .arg("rev-parse")
616            .arg("--short")
617            .arg("HEAD")
618            .current_dir(path)
619            .output()
620            .ok()
621            .and_then(|output| String::from_utf8(output.stdout).ok())
622            .map(|s| s.trim().to_string())
623            .unwrap_or_else(|| "unknown".to_string());
624
625        // Get last commit message (first line)
626        let commit_message = Command::new("git")
627            .arg("log")
628            .arg("-1")
629            .arg("--pretty=%s")
630            .current_dir(path)
631            .output()
632            .ok()
633            .and_then(|output| String::from_utf8(output.stdout).ok())
634            .map(|s| s.trim().to_string())
635            .unwrap_or_default();
636
637        // Check if repository is dirty
638        let is_dirty = Command::new("git")
639            .arg("status")
640            .arg("--porcelain")
641            .current_dir(path)
642            .output()
643            .ok()
644            .map(|output| !output.stdout.is_empty())
645            .unwrap_or(false);
646
647        // Get commits ahead/behind (if tracking upstream)
648        let (ahead, behind) = Command::new("git")
649            .arg("rev-list")
650            .arg("--left-right")
651            .arg("--count")
652            .arg("HEAD...@{upstream}")
653            .current_dir(path)
654            .output()
655            .ok()
656            .and_then(|output| String::from_utf8(output.stdout).ok())
657            .and_then(|s| {
658                let parts: Vec<&str> = s.trim().split('\t').collect();
659                if parts.len() == 2 {
660                    Some((parts[0].parse().unwrap_or(0), parts[1].parse().unwrap_or(0)))
661                } else {
662                    None
663                }
664            })
665            .unwrap_or((0, 0));
666
667        // Get last commit timestamp
668        let last_commit_date = Command::new("git")
669            .arg("log")
670            .arg("-1")
671            .arg("--pretty=%ct")
672            .current_dir(path)
673            .output()
674            .ok()
675            .and_then(|output| String::from_utf8(output.stdout).ok())
676            .and_then(|s| s.trim().parse::<u64>().ok())
677            .unwrap_or(0);
678
679        Some(GitInfo {
680            branch,
681            commit,
682            commit_message,
683            is_dirty,
684            ahead,
685            behind,
686            last_commit_date,
687        })
688    }
689
690    /// Detect key dependencies
691    fn detect_dependencies(&self, path: &Path, project_type: &ProjectType) -> Vec<String> {
692        let mut deps = Vec::new();
693
694        match project_type {
695            ProjectType::Rust => {
696                if let Ok(content) = fs::read_to_string(path.join("Cargo.toml")) {
697                    // Extract key dependencies
698                    for line in content.lines() {
699                        if line.contains("=") && !line.starts_with('[') {
700                            if let Some(dep) = line.split('=').next() {
701                                let dep = dep.trim().replace('"', "");
702                                if !dep.is_empty() && deps.len() < 5 {
703                                    deps.push(dep);
704                                }
705                            }
706                        }
707                    }
708                }
709            }
710            ProjectType::NodeJs => {
711                if let Ok(content) = fs::read_to_string(path.join("package.json")) {
712                    if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
713                        if let Some(dependencies) = json["dependencies"].as_object() {
714                            for (key, _) in dependencies.iter().take(5) {
715                                deps.push(key.to_string());
716                            }
717                        }
718                    }
719                }
720            }
721            _ => {}
722        }
723
724        deps
725    }
726
727    /// Generate HEX-like signature for project
728    fn generate_hex_signature(&self, name: &str, project_type: &ProjectType, size: u64) -> String {
729        let type_byte = match project_type {
730            ProjectType::Rust => 0x52,       // R
731            ProjectType::NodeJs => 0x4E,     // N
732            ProjectType::Python => 0x50,     // P
733            ProjectType::Go => 0x47,         // G
734            ProjectType::Java => 0x4A,       // J
735            ProjectType::DotNet => 0x44,     // D
736            ProjectType::Ruby => 0x52,       // R
737            ProjectType::Docker => 0x43,     // C (Container)
738            ProjectType::Kubernetes => 0x4B, // K
739            ProjectType::Monorepo => 0x4D,   // M
740            ProjectType::Unknown => 0x55,    // U
741            
742        };
743
744        // Simple hash from name
745        let name_hash = name.bytes().fold(0u16, |acc, b| acc.wrapping_add(b as u16));
746
747        // Size category (0-F)
748        let size_cat = match size {
749            0..=1024 => 0x1,
750            1025..=10240 => 0x2,
751            10241..=102400 => 0x4,
752            102401..=1048576 => 0x8,
753            _ => 0xF,
754        };
755
756        format!("{:02X}{:04X}{:X}", type_byte, name_hash, size_cat)
757    }
758}
759
760impl Formatter for ProjectsFormatter {
761    fn format(
762        &self,
763        writer: &mut dyn std::io::Write,
764        _nodes: &[FileNode],
765        _stats: &TreeStats,
766        root_path: &Path,
767    ) -> Result<()> {
768        let projects = self.scan_projects(root_path)?;
769
770        let mut output = String::new();
771
772        // Header
773        output.push_str(&format!(
774            "πŸ” Project Discovery: {} projects found\n",
775            projects.len()
776        ));
777        output.push_str("═".repeat(60).as_str());
778        output.push('\n');
779
780        for project in projects {
781            // HEX signature and type indicator
782            let type_icon = match project.project_type {
783                ProjectType::Rust => "πŸ¦€",
784                ProjectType::NodeJs => "πŸ“¦",
785                ProjectType::Python => "🐍",
786                ProjectType::Go => "🐹",
787                ProjectType::Java => "β˜•",
788                ProjectType::DotNet => "πŸ”·",
789                ProjectType::Ruby => "πŸ’Ž",
790                ProjectType::Docker => "🐳",
791                ProjectType::Kubernetes => "☸️",
792                ProjectType::Monorepo => "πŸ“š",
793                ProjectType::Unknown => "πŸ“",
794            };
795
796            // Format project entry with name and type
797            let project_name = if let Some(ref git) = project.git_info {
798                if git.is_dirty {
799                    format!("{} *", project.name) // Asterisk for dirty repos
800                } else {
801                    project.name.clone()
802                }
803            } else {
804                project.name.clone()
805            };
806
807            output.push_str(&format!(
808                "[{}] {} {}\n",
809                project.hex_signature, type_icon, project_name
810            ));
811
812            // Condensed summary
813            if !project.summary.is_empty() {
814                output.push_str(&format!("  └─ {}\n", project.summary));
815            }
816
817            // Path (relative if possible)
818            let display_path = if let Ok(cwd) = std::env::current_dir() {
819                project
820                    .path
821                    .strip_prefix(&cwd)
822                    .unwrap_or(&project.path)
823                    .display()
824                    .to_string()
825            } else {
826                project.path.display().to_string()
827            };
828            output.push_str(&format!("     πŸ“ {}\n", display_path));
829
830            // Git information
831            if let Some(ref git) = project.git_info {
832                let git_status = if git.ahead > 0 && git.behind > 0 {
833                    format!("↑{}↓{}", git.ahead, git.behind)
834                } else if git.ahead > 0 {
835                    format!("↑{}", git.ahead)
836                } else if git.behind > 0 {
837                    format!("↓{}", git.behind)
838                } else {
839                    String::new()
840                };
841
842                output.push_str(&format!(
843                    "     πŸ”€ {} @ {} {}\n",
844                    git.branch, git.commit, git_status
845                ));
846
847                if !git.commit_message.is_empty() {
848                    let msg = if git.commit_message.chars().count() > 50 {
849                        // Use char boundary-safe truncation for Unicode
850                        let mut truncated = String::new();
851                        for (char_count, ch) in git.commit_message.chars().enumerate() {
852                            if char_count >= 47 {
853                                break;
854                            }
855                            truncated.push(ch);
856                        }
857                        format!("{}...", truncated)
858                    } else {
859                        git.commit_message.clone()
860                    };
861                    output.push_str(&format!("        \"{}\"\n", msg));
862                }
863            }
864
865            // Timestamps
866            let created_str = format_timestamp(project.created);
867            let modified_str = format_timestamp(project.last_modified);
868            output.push_str(&format!(
869                "     πŸ“… Created: {}, Modified: {}\n",
870                created_str, modified_str
871            ));
872
873            // Quick stats
874            output.push_str(&format!(
875                "     πŸ“Š {} files, {}\n",
876                project.file_count,
877                format_size(project.size)
878            ));
879
880            // Dependencies (if any)
881            if self.show_dependencies && !project.dependencies.is_empty() {
882                let deps_str = if project.dependencies.len() > 3 {
883                    format!(
884                        "{}, +{} more",
885                        project.dependencies[..3].join(", "),
886                        project.dependencies.len() - 3
887                    )
888                } else {
889                    project.dependencies.join(", ")
890                };
891                output.push_str(&format!("     πŸ“¦ {}\n", deps_str));
892            }
893
894            output.push('\n');
895        }
896
897        writer.write_all(output.as_bytes())?;
898        Ok(())
899    }
900}
901
902fn format_size(size: u64) -> String {
903    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
904    let mut size = size as f64;
905    let mut unit_index = 0;
906
907    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
908        size /= 1024.0;
909        unit_index += 1;
910    }
911
912    if unit_index == 0 {
913        format!("{} {}", size as u64, UNITS[unit_index])
914    } else {
915        format!("{:.1} {}", size, UNITS[unit_index])
916    }
917}
918
919fn format_timestamp(timestamp: u64) -> String {
920    use chrono::{Local, TimeZone};
921
922    if timestamp == 0 || timestamp == u64::MAX {
923        return "unknown".to_string();
924    }
925
926    let dt = Local.timestamp_opt(timestamp as i64, 0).single();
927    if let Some(dt) = dt {
928        let now = Local::now();
929        let duration = now.signed_duration_since(dt);
930
931        if duration.num_days() == 0 {
932            return "today".to_string();
933        } else if duration.num_days() == 1 {
934            return "yesterday".to_string();
935        } else if duration.num_days() < 7 {
936            return format!("{} days ago", duration.num_days());
937        } else if duration.num_days() < 30 {
938            return format!("{} weeks ago", duration.num_weeks());
939        } else if duration.num_days() < 365 {
940            return format!("{} months ago", duration.num_days() / 30);
941        } else {
942            return format!("{} years ago", duration.num_days() / 365);
943        }
944    }
945
946    "unknown".to_string()
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952
953    #[test]
954    fn test_condense_text() {
955        let formatter = ProjectsFormatter::new();
956
957        let text = "This is a test project for machine learning and data analysis";
958        let condensed = formatter.condense_text(text);
959
960        // Should remove vowels and stopwords
961        assert!(!condensed.contains("This"));
962        assert!(!condensed.contains("is"));
963        assert!(!condensed.contains("a"));
964        assert!(condensed.contains("tst")); // "test" without vowels
965        assert!(condensed.contains("prjct")); // "project" without vowels
966    }
967
968    #[test]
969    fn test_project_type_detection() {
970        let formatter = ProjectsFormatter::new();
971        let temp_dir = tempfile::tempdir().unwrap();
972
973        // Create Rust project
974        std::fs::write(temp_dir.path().join("Cargo.toml"), "").unwrap();
975        assert_eq!(
976            formatter.detect_project_type(temp_dir.path()),
977            ProjectType::Rust
978        );
979
980        // Add package.json - should become Monorepo
981        std::fs::write(temp_dir.path().join("package.json"), "{}").unwrap();
982        assert_eq!(
983            formatter.detect_project_type(temp_dir.path()),
984            ProjectType::Monorepo
985        );
986    }
987
988    // #[test]
989    // fn test_hex_signature() {
990    //     let formatter = ProjectsFormatter::new();
991
992    //     let sig = formatter.generate_hex_signature("test-project", &ProjectType::Rust, 10240);
993    //     assert_eq!(sig.len(), 8); // 2 + 4 + 1 hex chars
994    //     assert!(sig.starts_with("52")); // Rust = 0x52
995    // }
996}