metis_docs_cli/commands/
list.rs

1use crate::workspace;
2use anyhow::Result;
3use clap::Args;
4use metis_core::{Application, Database, Result as MetisResult};
5
6#[derive(Args)]
7pub struct ListCommand {
8    /// Document type to filter by (vision, strategy, initiative, task, adr)
9    #[arg(short = 't', long)]
10    pub document_type: Option<String>,
11
12    /// Phase to filter by (draft, active, completed, etc.)
13    #[arg(short = 'p', long)]
14    pub phase: Option<String>,
15
16    /// Show all documents regardless of type
17    #[arg(short = 'a', long)]
18    pub all: bool,
19
20    /// Include archived documents in the list
21    #[arg(long)]
22    pub include_archived: bool,
23}
24
25impl ListCommand {
26    pub async fn execute(&self) -> Result<()> {
27        // 1. Validate we're in a metis workspace
28        let (workspace_exists, metis_dir) = workspace::has_metis_vault();
29        if !workspace_exists {
30            anyhow::bail!("Not in a Metis workspace. Run 'metis init' to create one.");
31        }
32        let metis_dir = metis_dir.unwrap();
33
34        // 2. Sync before reading to catch external edits
35        let db_path = metis_dir.join("metis.db");
36        let database = Database::new(db_path.to_str().unwrap())
37            .map_err(|e| anyhow::anyhow!("Failed to open database for sync: {}", e))?;
38        let app = Application::new(database);
39        app.sync_directory(&metis_dir)
40            .await
41            .map_err(|e| anyhow::anyhow!("Failed to sync workspace: {}", e))?;
42
43        // 3. Connect to database
44        let db = Database::new(db_path.to_str().unwrap())
45            .map_err(|e| anyhow::anyhow!("Database connection failed: {}", e))?;
46        let mut repo = db.into_repository();
47
48        // 4. Query documents based on filters
49        let documents = if self.all {
50            // Show all documents
51            self.list_all_documents(&mut repo).await?
52        } else if let Some(doc_type) = &self.document_type {
53            if let Some(phase) = &self.phase {
54                // Filter by both type and phase
55                repo.find_by_type_and_phase(doc_type, phase)
56                    .map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?
57            } else {
58                // Filter by type only
59                repo.find_by_type(doc_type)
60                    .map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?
61            }
62        } else if let Some(phase) = &self.phase {
63            // Filter by phase only
64            repo.find_by_phase(phase)
65                .map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?
66        } else {
67            // Default: show all documents
68            self.list_all_documents(&mut repo).await?
69        };
70
71        // 5. Display results
72        if documents.is_empty() {
73            println!("No documents found matching the criteria.");
74            return Ok(());
75        }
76
77        self.display_documents(&documents);
78
79        Ok(())
80    }
81
82    async fn list_all_documents(
83        &self,
84        repo: &mut metis_core::dal::database::repository::DocumentRepository,
85    ) -> MetisResult<Vec<metis_core::dal::database::models::Document>> {
86        // For listing all documents, we can query each type
87        let mut all_docs = Vec::new();
88
89        // Collect all document types
90        for doc_type in ["vision", "strategy", "initiative", "task", "adr"] {
91            let mut docs = repo.find_by_type(doc_type)?;
92            all_docs.append(&mut docs);
93        }
94
95        // Filter out archived documents unless requested
96        if !self.include_archived {
97            all_docs.retain(|doc| !doc.archived);
98        }
99
100        // Sort by updated_at descending
101        all_docs.sort_by(|a, b| {
102            b.updated_at
103                .partial_cmp(&a.updated_at)
104                .unwrap_or(std::cmp::Ordering::Equal)
105        });
106
107        Ok(all_docs)
108    }
109
110    fn display_documents(&self, documents: &[metis_core::dal::database::models::Document]) {
111        println!(
112            "\n{:<15} {:<30} {:<15} {:<15} {:<20}",
113            "TYPE", "TITLE", "PHASE", "ID", "UPDATED"
114        );
115        println!("{}", "-".repeat(95));
116
117        for doc in documents {
118            let updated = chrono::DateTime::from_timestamp(doc.updated_at as i64, 0)
119                .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
120                .unwrap_or_else(|| "Unknown".to_string());
121
122            println!(
123                "{:<15} {:<30} {:<15} {:<15} {:<20}",
124                doc.document_type,
125                self.truncate_string(&doc.title, 28),
126                doc.phase,
127                self.truncate_string(&doc.id, 13),
128                updated
129            );
130        }
131
132        println!("\nTotal: {} documents", documents.len());
133    }
134
135    fn truncate_string(&self, s: &str, max_len: usize) -> String {
136        if s.len() <= max_len {
137            s.to_string()
138        } else {
139            format!("{}...", &s[..max_len.saturating_sub(3)])
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::commands::InitCommand;
148    use tempfile::tempdir;
149
150    #[tokio::test]
151    async fn test_list_command_no_workspace() {
152        let temp_dir = tempdir().unwrap();
153        let original_dir = std::env::current_dir().ok();
154
155        // Change to temp directory without workspace
156        if std::env::set_current_dir(temp_dir.path()).is_err() {
157            return; // Skip test if we can't change directory
158        }
159
160        let cmd = ListCommand {
161            document_type: None,
162            phase: None,
163            all: false,
164            include_archived: false,
165        };
166
167        let result = cmd.execute().await;
168
169        // Always restore original directory first
170        if let Some(original) = original_dir {
171            let _ = std::env::set_current_dir(&original);
172        }
173
174        assert!(result.is_err());
175        assert!(result
176            .unwrap_err()
177            .to_string()
178            .contains("Not in a Metis workspace"));
179    }
180
181    #[tokio::test]
182    async fn test_list_command_empty_workspace() {
183        let temp_dir = tempdir().unwrap();
184        let original_dir = std::env::current_dir().ok();
185
186        // Change to temp directory
187        std::env::set_current_dir(temp_dir.path()).unwrap();
188
189        // Create workspace
190        let init_cmd = InitCommand {
191            name: Some("Test Project".to_string()),
192            preset: None,
193            strategies: None,
194            initiatives: None,
195            prefix: None,
196        };
197        init_cmd.execute().await.unwrap();
198
199        let cmd = ListCommand {
200            document_type: None,
201            phase: None,
202            all: true,
203            include_archived: false,
204        };
205
206        let result = cmd.execute().await;
207
208        // Always restore original directory first
209        if let Some(original) = original_dir {
210            let _ = std::env::set_current_dir(&original);
211        }
212
213        // Should succeed but show no documents (except the vision.md created by init)
214        assert!(result.is_ok());
215    }
216}