rust_filesearch/px/
commands.rs

1//! CLI command implementations
2//!
3//! Implements the core px commands: list, open, info, sync, init
4
5use crate::config::PxConfig;
6use crate::errors::{FsError, Result};
7use crate::px::index::ProjectIndex;
8use crate::px::search::ProjectSearcher;
9use chrono::Duration;
10use std::path::PathBuf;
11use std::process::Command;
12
13/// Initialize px configuration
14pub fn cmd_init() -> Result<()> {
15    PxConfig::init()
16}
17
18/// Rebuild the project index by scanning configured directories
19pub fn cmd_sync(index: &mut ProjectIndex, scan_dirs: &[PathBuf]) -> Result<()> {
20    if scan_dirs.is_empty() {
21        println!("⚠️  No scan directories configured!");
22        println!("Run `px init` to create a config file, then edit:");
23        println!("  {}", PxConfig::config_file_path()?.display());
24        return Ok(());
25    }
26
27    println!("Scanning {} directories...", scan_dirs.len());
28    for dir in scan_dirs {
29        println!("  • {}", dir.display());
30    }
31    println!();
32
33    let start = std::time::Instant::now();
34    let count = index.sync(scan_dirs)?;
35    let elapsed = start.elapsed();
36
37    println!("✓ Indexed {} projects in {:.2}s", count, elapsed.as_secs_f64());
38
39    Ok(())
40}
41
42/// List all projects with optional filtering
43pub fn cmd_list(index: &ProjectIndex, filter: Option<String>) -> Result<()> {
44    let mut projects: Vec<_> = index.sorted_projects();
45
46    // Apply filters
47    if let Some(ref filter_name) = filter {
48        projects.retain(|p| match filter_name.as_str() {
49            "has-changes" => p.git_status.has_uncommitted,
50            "inactive-30d" => {
51                let cutoff = chrono::Utc::now() - Duration::days(30);
52                p.last_accessed.map_or(true, |t| t < cutoff)
53            }
54            "inactive-90d" => {
55                let cutoff = chrono::Utc::now() - Duration::days(90);
56                p.last_accessed.map_or(true, |t| t < cutoff)
57            }
58            _ => true,
59        });
60    }
61
62    if projects.is_empty() {
63        if filter.is_some() {
64            println!("No projects found matching filter");
65        } else {
66            println!("No projects indexed yet. Run `px sync` to scan for projects.");
67        }
68        return Ok(());
69    }
70
71    // Print header
72    println!("{:<30} {:<15} {:<8}", "Project", "Branch", "Status");
73    println!("{}", "─".repeat(60));
74
75    // Print projects
76    for project in &projects {
77        let status = if project.git_status.has_uncommitted {
78            "⚠ changes"
79        } else if project.git_status.ahead > 0 {
80            "↑ ahead"
81        } else if project.git_status.behind > 0 {
82            "↓ behind"
83        } else {
84            "✓ clean"
85        };
86
87        println!(
88            "{:<30} {:<15} {:<8}",
89            truncate(&project.name, 28),
90            truncate(&project.git_status.current_branch, 13),
91            status
92        );
93    }
94
95    println!();
96    println!("Total: {} projects", projects.len());
97
98    Ok(())
99}
100
101/// Open a project in an editor and iTerm2
102pub fn cmd_open(index: &mut ProjectIndex, query: &str, editor: &str) -> Result<()> {
103    let searcher = ProjectSearcher::new();
104    let projects: Vec<_> = index.projects.values().cloned().collect();
105    let results = searcher.search(&projects, query);
106
107    if results.is_empty() {
108        println!("No projects found matching '{}'", query);
109        return Ok(());
110    }
111
112    let project = results[0];
113    let project_path = project.path.clone();
114    let project_name = project.name.clone();
115
116    println!("Opening {} in {} + iTerm2...", project_name, editor);
117    println!("  Path: {}", project_path.display());
118
119    // Spawn editor
120    let editor_status = Command::new(editor)
121        .arg(&project_path)
122        .status()
123        .map_err(|e| FsError::IoError {
124            context: format!("Failed to spawn editor '{}'", editor),
125            source: e,
126        })?;
127
128    if !editor_status.success() {
129        eprintln!("⚠️  Editor '{}' exited with error", editor);
130    }
131
132    // Open iTerm2 window at project directory
133    let applescript = format!(
134        r#"
135        tell application "iTerm"
136            create window with default profile
137            tell current session of current window
138                write text "cd '{}'"
139                write text "clear"
140            end tell
141        end tell
142        "#,
143        project_path.display()
144    );
145
146    let iterm_result = Command::new("osascript")
147        .arg("-e")
148        .arg(&applescript)
149        .status();
150
151    match iterm_result {
152        Ok(status) if status.success() => {
153            println!("✓ Opened iTerm2 window at project directory");
154        }
155        Ok(_) => {
156            eprintln!("⚠️  Failed to open iTerm2 window (check if iTerm2 is installed)");
157        }
158        Err(e) => {
159            eprintln!("⚠️  Could not execute osascript: {}", e);
160        }
161    }
162
163    // Record access for frecency tracking
164    index.record_access(&project_path.to_string_lossy())?;
165
166    Ok(())
167}
168
169/// Show detailed project information
170pub fn cmd_info(index: &ProjectIndex, query: &str) -> Result<()> {
171    let searcher = ProjectSearcher::new();
172    let projects: Vec<_> = index.projects.values().cloned().collect();
173    let results = searcher.search(&projects, query);
174
175    if results.is_empty() {
176        println!("No projects found matching '{}'", query);
177        return Ok(());
178    }
179
180    let project = results[0];
181
182    // Project header
183    println!();
184    println!("📁 {}", project.name);
185    println!("{}", "=".repeat(60));
186
187    // Basic info
188    println!("Path:     {}", project.path.display());
189    println!("Branch:   {}", project.git_status.current_branch);
190
191    // Git status
192    let status = if project.git_status.has_uncommitted {
193        "⚠️  Uncommitted changes"
194    } else {
195        "✓ Clean"
196    };
197    println!("Status:   {}", status);
198
199    // Ahead/behind
200    if project.git_status.ahead > 0 || project.git_status.behind > 0 {
201        println!(
202            "Sync:     ↑ {} ahead, ↓ {} behind",
203            project.git_status.ahead, project.git_status.behind
204        );
205    }
206
207    // Last commit
208    if let Some(ref commit) = project.git_status.last_commit {
209        println!();
210        println!("Last commit:");
211        println!("  {} - {}", commit.hash, commit.message);
212        println!(
213            "  by {} at {}",
214            commit.author,
215            commit.timestamp.format("%Y-%m-%d %H:%M")
216        );
217    }
218
219    // README excerpt
220    if let Some(ref readme) = project.readme_excerpt {
221        println!();
222        println!("README:");
223        println!("  {}", readme);
224    }
225
226    // Frecency stats
227    if project.access_count > 0 {
228        println!();
229        println!("Access stats:");
230        println!("  Count:   {}", project.access_count);
231        if let Some(last_access) = project.last_accessed {
232            println!(
233                "  Last:    {}",
234                last_access.format("%Y-%m-%d %H:%M")
235            );
236        }
237        println!("  Score:   {:.1}", project.frecency_score);
238    }
239
240    println!();
241
242    Ok(())
243}
244
245/// Helper to truncate strings with ellipsis
246fn truncate(s: &str, max_len: usize) -> String {
247    if s.len() <= max_len {
248        s.to_string()
249    } else {
250        format!("{}...", &s[..max_len - 3])
251    }
252}
253