use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Serialize;
use cartog_db::Database;
use cartog_registry::DeclaredUpdate;
use crate::config::{resolve_project_at, DeclaredAtRoot};
use crate::registry_hook::{record_backfilled, record_declared, resolve_declared};
#[derive(Debug, Serialize)]
struct AddedJson {
registered: Vec<RegisteredJson>,
#[serde(skip_serializing_if = "super::super::shared::is_zero")]
skipped_no_index: usize,
dry_run: bool,
}
#[derive(Debug, Serialize)]
struct RegisteredJson {
root: String,
db_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
symbol_count: Option<u32>,
}
pub fn cmd_projects_add(path: &str, json: bool) -> Result<()> {
require_registry()?;
let root = absolutize(Path::new(path))
.with_context(|| format!("could not resolve the project root '{path}'"))?;
if !root.is_dir() {
bail!("'{}' is not a directory", root.display());
}
let outcome = register_root(&root, false);
let payload = match outcome {
Outcome::Registered(row) => AddedJson {
registered: vec![row],
skipped_no_index: 0,
dry_run: false,
},
Outcome::NoIndex => bail!(
"no cartog index at '{}' — run `cartog index` there first.\n\
`projects add` registers an index that already exists; it never creates one.",
root.display()
),
};
super::super::shared::output(&payload, json, None, |p| {
let row = &p.registered[0];
format!(
"Registered '{}'.\n database: {}\n\
Its graph was not re-indexed, so `projects list` shows it as last indexed \
'never' until the next `cartog index` there.\n",
row.root, row.db_path,
)
})
}
pub fn cmd_projects_scan(dir: &str, depth: u32, dry_run: bool, json: bool) -> Result<()> {
require_registry()?;
let base = absolutize(Path::new(dir))
.with_context(|| format!("could not resolve the scan directory '{dir}'"))?;
if !base.is_dir() {
bail!("'{}' is not a directory", base.display());
}
let mut registered = Vec::new();
let mut skipped_no_index = 0usize;
for root in candidate_roots(&base, depth) {
match register_root(&root, dry_run) {
Outcome::Registered(row) => registered.push(row),
Outcome::NoIndex => skipped_no_index += 1,
}
}
let payload = AddedJson {
registered,
skipped_no_index,
dry_run,
};
super::super::shared::output(&payload, json, None, |p| {
if p.registered.is_empty() {
return format!(
"No cartog index found under '{}' ({} director{} searched, {} level{} deep).\n",
base.display(),
p.skipped_no_index,
if p.skipped_no_index == 1 { "y" } else { "ies" },
depth,
if depth == 1 { "" } else { "s" },
);
}
let verb = if p.dry_run {
"Would register"
} else {
"Registered"
};
let mut out = format!(
"{verb} {} project{}:\n",
p.registered.len(),
if p.registered.len() == 1 { "" } else { "s" },
);
for row in &p.registered {
let counts = row
.symbol_count
.map_or_else(|| "not measurable".to_string(), |n| format!("{n} symbols"));
out.push_str(&format!(" {} ({counts})\n", row.root));
}
if p.dry_run {
out.push_str("\nNothing was written. Re-run without --dry-run to register these.\n");
}
out
})
}
enum Outcome {
Registered(RegisteredJson),
NoIndex,
}
fn register_root(root: &Path, dry_run: bool) -> Outcome {
let resolved = resolve_project_at(root);
if !resolved.db_path.is_file() {
return Outcome::NoIndex;
}
let declared = match &resolved.declared {
DeclaredAtRoot::Known { name, description } => DeclaredUpdate::Set(resolve_declared(
name.as_deref(),
description.as_deref(),
root,
)),
DeclaredAtRoot::Unreadable => DeclaredUpdate::Keep,
};
let measured = Database::open_readonly(&resolved.db_path).ok();
let symbol_count = measured
.as_ref()
.and_then(|db| db.stats().ok())
.map(|s| s.num_symbols);
if !dry_run {
match measured {
Some(db) => record_backfilled(&db, &resolved.db_path, root, declared),
None => record_declared(&resolved.db_path, root, declared),
}
}
Outcome::Registered(RegisteredJson {
root: root.display().to_string(),
db_path: resolved.db_path.display().to_string(),
symbol_count,
})
}
fn candidate_roots(base: &Path, depth: u32) -> Vec<PathBuf> {
let mut out = vec![base.to_path_buf()];
let mut frontier = vec![base.to_path_buf()];
for _ in 0..depth {
let mut next = Vec::new();
for dir in &frontier {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
if is_uninteresting_dirname(&path) {
continue;
}
out.push(path.clone());
next.push(path);
}
}
frontier = next;
}
out
}
fn is_uninteresting_dirname(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return true;
};
name.starts_with('.')
|| matches!(
name,
"node_modules" | "target" | "vendor" | "dist" | "build" | "venv" | "__pycache__"
)
}
fn absolutize(path: &Path) -> Result<PathBuf> {
let expanded = crate::config::expand_tilde(path.to_path_buf());
if expanded.is_absolute() {
return Ok(expanded);
}
Ok(std::env::current_dir()
.context("could not read the current directory")?
.join(expanded))
}
fn require_registry() -> Result<()> {
if cartog_registry::registry_path().is_none() {
bail!(
"no project registry on this machine (CARTOG_REGISTRY is disabled, \
or no state directory could be resolved)"
);
}
Ok(())
}