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 #[command(alias = "analyze")]
41 Chart {
42 source: Option<String>,
44 #[arg(long = "no-gitignore", default_value_t = false)]
46 no_gitignore: bool,
47 #[arg(long = "include-root", value_delimiter = ',')]
49 include_roots: Vec<String>,
50 #[arg(long, default_value_t = false)]
52 track_workspace: bool,
53 #[arg(long, default_value_t = false)]
55 no_open: bool,
56 #[arg(long, default_value_t = false)]
58 non_interactive: bool,
59 #[arg(long, default_value_t = DEFAULT_PORT)]
61 port: u16,
62 },
63 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}