Skip to main content

quartermaster_cli/
lib.rs

1use anyhow::{anyhow, Result};
2use clap::{Parser, Subcommand};
3use colored::*;
4use crossterm::{terminal::Clear, ExecutableCommand};
5use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect};
6use std::collections::HashSet;
7use std::fs;
8use std::io::{stdout, Write};
9use std::path::Path;
10use std::thread;
11use std::time::Duration;
12
13mod analyzer;
14mod art;
15mod generator;
16mod scanner;
17mod server;
18
19use analyzer::{AnalysisResult, RepoAnalyzer};
20use art::{display_anchor, display_logo, display_starfield, display_title};
21use generator::{DocGenerator, GeneratedWorkspace, WORKSPACE_DIR_NAME};
22use scanner::{PreparedRepo, RepoInfo, RepoScanner, RootEntry};
23use server::launch_dashboard;
24
25const DEFAULT_PORT: u16 = 4210;
26
27#[derive(Parser)]
28#[command(name = "quartermaster")]
29#[command(about = "Generate repo docs and launch the Quartermaster dashboard")]
30#[command(version = "1.1.0")]
31#[command(author = "Quartermaster Team")]
32struct Cli {
33    #[command(subcommand)]
34    command: Option<Commands>,
35}
36
37#[derive(Subcommand)]
38enum Commands {
39    /// Chart a repository and open the dashboard
40    #[command(alias = "analyze")]
41    Chart {
42        /// GitHub repository URL or local path. Defaults to the current directory in non-interactive mode.
43        source: Option<String>,
44        /// Skip gitignored files when scanning
45        #[arg(long = "no-gitignore", default_value_t = false)]
46        no_gitignore: bool,
47        /// Root-level files or folders to include
48        #[arg(long = "include-root", value_delimiter = ',')]
49        include_roots: Vec<String>,
50        /// Track ./.quartermaster in git
51        #[arg(long, default_value_t = false)]
52        track_workspace: bool,
53        /// Do not launch the dashboard after generation
54        #[arg(long, default_value_t = false)]
55        no_open: bool,
56        /// Skip interactive prompts and use defaults
57        #[arg(long, default_value_t = false)]
58        non_interactive: bool,
59        /// Local dashboard port
60        #[arg(long, default_value_t = DEFAULT_PORT)]
61        port: u16,
62    },
63    /// Initialize Quartermaster defaults
64    Init,
65}
66
67#[derive(Debug, Clone)]
68struct AnalyzeOptions {
69    source: String,
70    respect_gitignore: bool,
71    include_roots: Vec<String>,
72    keep_workspace_untracked: bool,
73    open_dashboard: bool,
74    non_interactive: bool,
75    port: u16,
76}
77
78#[derive(Default)]
79struct PipelineState {
80    repo_info: Option<RepoInfo>,
81    analysis: Option<AnalysisResult>,
82    workspace: Option<GeneratedWorkspace>,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86enum PipelineStepId {
87    Scan,
88    Analyze,
89    GenerateDocs,
90}
91
92struct PipelineStep {
93    id: PipelineStepId,
94    depends_on: &'static [PipelineStepId],
95}
96
97pub fn run() -> Result<()> {
98    let cli = Cli::parse();
99    display_introduction()?;
100
101    match cli.command {
102        Some(Commands::Chart {
103            source,
104            no_gitignore,
105            include_roots,
106            track_workspace,
107            no_open,
108            non_interactive,
109            port,
110        }) => {
111            let options = AnalyzeOptions {
112                source: source.unwrap_or_else(|| ".".to_string()),
113                respect_gitignore: !no_gitignore,
114                include_roots,
115                keep_workspace_untracked: !track_workspace,
116                open_dashboard: !no_open,
117                non_interactive,
118                port,
119            };
120            analyze_repository(options)?;
121        }
122        Some(Commands::Init) => initialize_config()?,
123        None => {
124            let options = build_interactive_options()?;
125            analyze_repository(options)?;
126        }
127    }
128
129    Ok(())
130}
131
132fn display_introduction() -> Result<()> {
133    stdout().execute(Clear(crossterm::terminal::ClearType::All))?;
134
135    display_logo()?;
136    thread::sleep(Duration::from_millis(500));
137
138    display_starfield()?;
139    thread::sleep(Duration::from_millis(300));
140
141    display_title()?;
142    println!();
143    println!(
144        "{}",
145        "✦ Navigate the constellations of your codebase ✦".bright_cyan()
146    );
147    println!();
148    println!(
149        "{}",
150        "Quartermaster scans your repository, creates versioned docs in ./.quartermaster,"
151            .bright_black()
152    );
153    println!(
154        "{}",
155        "preserves your notes, and opens a dashboard that hydrates from the latest generated pass."
156            .bright_black()
157    );
158    println!();
159    let _ = display_anchor();
160
161    Ok(())
162}
163
164fn build_interactive_options() -> Result<AnalyzeOptions> {
165    let theme = ColorfulTheme::default();
166    let source = Input::with_theme(&theme)
167        .with_prompt("Repository source (local path or GitHub URL)")
168        .default(".".to_string())
169        .interact_text()?;
170
171    let respect_gitignore = Confirm::with_theme(&theme)
172        .with_prompt("Respect .gitignore while scanning?")
173        .default(true)
174        .interact()?;
175
176    let track_workspace = Confirm::with_theme(&theme)
177        .with_prompt("Track ./.quartermaster in git?")
178        .default(false)
179        .interact()?;
180
181    Ok(AnalyzeOptions {
182        source,
183        respect_gitignore,
184        include_roots: Vec::new(),
185        keep_workspace_untracked: !track_workspace,
186        open_dashboard: true,
187        non_interactive: false,
188        port: DEFAULT_PORT,
189    })
190}
191
192fn analyze_repository(mut options: AnalyzeOptions) -> Result<()> {
193    println!();
194    println!("{}", "βš“ Charting your repository...".bright_blue().bold());
195    println!();
196
197    let scanner = RepoScanner::new(options.respect_gitignore);
198    display_loading_animation("Charting course to repository")?;
199    let prepared = scanner.prepare(&options.source)?;
200
201    let root_entries = scanner.list_root_entries(&prepared.path, WORKSPACE_DIR_NAME)?;
202    let selected_roots = resolve_selected_roots(&root_entries, &mut options)?;
203
204    if options.keep_workspace_untracked {
205        maybe_add_workspace_to_gitignore(&prepared.path, WORKSPACE_DIR_NAME)?;
206    }
207
208    let state = run_pipeline(&scanner, prepared, selected_roots)?;
209    let repo_info = state
210        .repo_info
211        .ok_or_else(|| anyhow!("Repository scan did not complete"))?;
212    let analysis = state
213        .analysis
214        .ok_or_else(|| anyhow!("Analysis did not complete"))?;
215    let workspace = state
216        .workspace
217        .ok_or_else(|| anyhow!("Workspace generation did not complete"))?;
218
219    print_summary(&repo_info, &analysis, &workspace);
220
221    if options.open_dashboard {
222        launch_dashboard(workspace.root.clone(), options.port)?;
223    }
224
225    Ok(())
226}
227
228fn resolve_selected_roots(
229    root_entries: &[RootEntry],
230    options: &mut AnalyzeOptions,
231) -> Result<Vec<String>> {
232    if !options.include_roots.is_empty() {
233        return Ok(options.include_roots.clone());
234    }
235
236    let defaults = root_entries.iter().map(|_| true).collect::<Vec<_>>();
237
238    if options.non_interactive || root_entries.is_empty() {
239        options.include_roots = root_entries
240            .iter()
241            .map(|entry| entry.relative_path.clone())
242            .collect();
243        return Ok(options.include_roots.clone());
244    }
245
246    let labels = root_entries
247        .iter()
248        .map(|entry| {
249            if entry.is_dir {
250                format!("πŸ“ {}", entry.relative_path)
251            } else {
252                format!("πŸ“„ {}", entry.relative_path)
253            }
254        })
255        .collect::<Vec<_>>();
256
257    let selected_indices = MultiSelect::with_theme(&ColorfulTheme::default())
258        .with_prompt("Which root-level folders/files should Quartermaster generate docs for?")
259        .items(&labels)
260        .defaults(&defaults)
261        .interact()?;
262
263    options.include_roots = if selected_indices.is_empty() {
264        root_entries
265            .iter()
266            .map(|entry| entry.relative_path.clone())
267            .collect()
268    } else {
269        selected_indices
270            .into_iter()
271            .filter_map(|index| root_entries.get(index))
272            .map(|entry| entry.relative_path.clone())
273            .collect()
274    };
275
276    Ok(options.include_roots.clone())
277}
278
279fn run_pipeline(
280    scanner: &RepoScanner,
281    prepared: PreparedRepo,
282    selected_roots: Vec<String>,
283) -> Result<PipelineState> {
284    let mut state = PipelineState::default();
285
286    for step_id in topological_steps()? {
287        match step_id {
288            PipelineStepId::Scan => {
289                display_loading_animation("Scanning repository tree")?;
290                state.repo_info = Some(scanner.scan_prepared(
291                    prepared.clone(),
292                    &selected_roots,
293                    WORKSPACE_DIR_NAME,
294                )?);
295            }
296            PipelineStepId::Analyze => {
297                display_loading_animation("Extracting stack, graph, and overview")?;
298                let analyzer = RepoAnalyzer::new();
299                let repo_info = state
300                    .repo_info
301                    .as_ref()
302                    .ok_or_else(|| anyhow!("Scan step missing"))?;
303                state.analysis = Some(analyzer.analyze(repo_info)?);
304            }
305            PipelineStepId::GenerateDocs => {
306                display_loading_animation("Generating ./.quartermaster workspace")?;
307                let generator = DocGenerator::new();
308                let repo_info = state
309                    .repo_info
310                    .as_ref()
311                    .ok_or_else(|| anyhow!("Scan step missing"))?;
312                let analysis = state
313                    .analysis
314                    .as_ref()
315                    .ok_or_else(|| anyhow!("Analyze step missing"))?;
316                state.workspace = Some(generator.generate(repo_info, analysis)?);
317            }
318        }
319    }
320
321    Ok(state)
322}
323
324fn topological_steps() -> Result<Vec<PipelineStepId>> {
325    let steps = vec![
326        PipelineStep {
327            id: PipelineStepId::Scan,
328            depends_on: &[],
329        },
330        PipelineStep {
331            id: PipelineStepId::Analyze,
332            depends_on: &[PipelineStepId::Scan],
333        },
334        PipelineStep {
335            id: PipelineStepId::GenerateDocs,
336            depends_on: &[PipelineStepId::Analyze],
337        },
338    ];
339
340    let mut resolved = Vec::new();
341    let mut remaining = steps.iter().map(|step| step.id).collect::<HashSet<_>>();
342
343    while !remaining.is_empty() {
344        let next = steps
345            .iter()
346            .find(|step| {
347                remaining.contains(&step.id)
348                    && step
349                        .depends_on
350                        .iter()
351                        .all(|dependency| resolved.contains(dependency))
352            })
353            .ok_or_else(|| anyhow!("Quartermaster pipeline contains a cycle"))?;
354
355        remaining.remove(&next.id);
356        resolved.push(next.id);
357    }
358
359    Ok(resolved)
360}
361
362fn maybe_add_workspace_to_gitignore(repo_root: &Path, workspace_dir_name: &str) -> Result<()> {
363    let git_dir = repo_root.join(".git");
364    if !git_dir.exists() {
365        return Ok(());
366    }
367
368    let gitignore_path = repo_root.join(".gitignore");
369    let entry = format!("/{workspace_dir_name}/");
370    let current = fs::read_to_string(&gitignore_path).unwrap_or_default();
371
372    if current.lines().any(|line| line.trim() == entry) {
373        return Ok(());
374    }
375
376    let separator = if current.is_empty() || current.ends_with('\n') {
377        ""
378    } else {
379        "\n"
380    };
381    let updated = format!("{current}{separator}{entry}\n");
382    fs::write(gitignore_path, updated)?;
383    Ok(())
384}
385
386fn initialize_config() -> Result<()> {
387    println!();
388    println!(
389        "{}",
390        "βš™οΈ  Quartermaster is ready to generate local workspaces."
391            .bright_yellow()
392            .bold()
393    );
394    println!(
395        "{}",
396        "Use `quartermaster chart`, `qm chart`, or just run `quartermaster` to start the interactive flow."
397            .bright_white()
398    );
399    Ok(())
400}
401
402fn display_loading_animation(message: &str) -> Result<()> {
403    let spinner_chars = ["β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ‡", "⠏"];
404
405    for index in 0..12 {
406        print!(
407            "\r{} {} {}",
408            spinner_chars[index % spinner_chars.len()].bright_cyan(),
409            message.bright_white(),
410            ".".repeat((index / 2) % 4).bright_black()
411        );
412        stdout().flush()?;
413        thread::sleep(Duration::from_millis(70));
414    }
415
416    print!("\rβœ… {}", "Done!".bright_green());
417    stdout().flush()?;
418    print!(" {}", message.bright_white());
419    stdout().flush()?;
420    println!();
421    Ok(())
422}
423
424fn print_summary(repo_info: &RepoInfo, analysis: &AnalysisResult, workspace: &GeneratedWorkspace) {
425    println!();
426    println!("{}", "πŸ—ΊοΈ  Analysis complete".bright_green().bold());
427    println!(
428        "{}",
429        format!("πŸ“ Repo: {}", repo_info.path.display()).bright_white()
430    );
431    println!(
432        "{}",
433        format!(
434            "🧭 Scope: {}",
435            if repo_info.selected_roots.is_empty() {
436                "all root entries".to_string()
437            } else {
438                repo_info.selected_roots.join(", ")
439            }
440        )
441        .bright_white()
442    );
443    println!(
444        "{}",
445        format!(
446            "πŸ“Š Files: {} | LoC: {}",
447            analysis.total_files, analysis.lines_of_code
448        )
449        .bright_white()
450    );
451    println!(
452        "{}",
453        format!("πŸŒ‰ Stack: {}", analysis.tech_stack.join(", ")).bright_white()
454    );
455    println!(
456        "{}",
457        format!("πŸ“ Workspace: {}", workspace.root.display()).bright_white()
458    );
459    println!(
460        "{}",
461        format!("πŸ•°οΈ  Active version: {}", workspace.version_id).bright_white()
462    );
463
464    if let Some(git_info) = &repo_info.git_info {
465        println!(
466            "{}",
467            format!(
468                "πŸ‘₯ Contributors inferred from git: {}",
469                git_info.contributors.len()
470            )
471            .bright_white()
472        );
473    }
474
475    println!();
476}