use anyhow::Result;
use serde::Serialize;
use cartog_registry::{Listing, ProjectRow};
#[derive(Debug, Serialize)]
pub(crate) struct ProjectJson {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description_source: Option<&'static str>,
pub root: String,
pub db_path: String,
pub languages: Vec<LanguageJson>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edge_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution_rate: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedding_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embed_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embed_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embed_dim: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_version: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_indexed: Option<String>,
pub last_seen: String,
pub live: bool,
pub stale_schema: bool,
pub missing: bool,
pub embed_mismatch: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct LanguageJson {
pub language: String,
pub symbols: u32,
}
#[derive(Debug, Serialize)]
pub(crate) struct ProjectsJson {
pub registry_available: bool,
pub projects: Vec<ProjectJson>,
}
pub fn cmd_projects_list(json: bool, tokens: Option<u32>) -> Result<()> {
let listing = cartog_registry::list_projects(cartog_db::CURRENT_SCHEMA_VERSION);
let payload = to_json(&listing);
super::super::shared::output(&payload, json, tokens, |_| render(&listing))
}
pub(crate) fn to_json(listing: &Listing) -> ProjectsJson {
ProjectsJson {
registry_available: listing.available,
projects: listing.projects.iter().map(row_to_json).collect(),
}
}
fn row_to_json(row: &ProjectRow) -> ProjectJson {
ProjectJson {
id: row.id.clone(),
name: row.display_name().to_string(),
description: row.description.as_ref().map(|d| d.text.clone()),
description_source: row.description.as_ref().map(|d| d.source.as_str()),
root: row.root.display().to_string(),
db_path: row.db_path.display().to_string(),
languages: row
.languages
.iter()
.map(|(language, symbols)| LanguageJson {
language: language.clone(),
symbols: *symbols,
})
.collect(),
file_count: row.file_count,
symbol_count: row.symbol_count,
edge_count: row.edge_count,
resolved_count: row.resolved_count,
resolution_rate: resolution_rate(row.resolved_count, row.edge_count),
embedding_count: row.embedding_count,
embed_provider: row.embed_provider.clone(),
embed_model: row.embed_model.clone(),
embed_dim: row.embed_dim,
schema_version: row.schema_version,
last_indexed: row.last_indexed.map(format_unix),
last_seen: format_unix(row.last_seen),
live: row.markers.live,
stale_schema: row.markers.stale_schema,
missing: row.markers.missing,
embed_mismatch: row.markers.embed_mismatch,
}
}
pub(crate) fn resolution_rate(resolved: Option<u32>, edges: Option<u32>) -> Option<f64> {
match (resolved, edges) {
(Some(r), Some(e)) if e > 0 => Some(f64::from(r) / f64::from(e)),
_ => None,
}
}
fn format_unix(secs: i64) -> String {
cartog_registry::format_timestamp(secs)
}
pub(crate) fn render(listing: &Listing) -> String {
if !listing.available {
return "No project registry found — nothing has been indexed on this machine yet, \
or CARTOG_REGISTRY is disabled.\n"
.to_string();
}
if listing.projects.is_empty() {
return "No projects registered yet. Run `cartog index` in a project to add it.\n"
.to_string();
}
let mut out = String::new();
for row in &listing.projects {
out.push_str(&render_row(row));
}
out.push_str(&format!(
"\n{} project{} registered. Query another with `cartog <command> --db <db_path>`.\n",
listing.projects.len(),
if listing.projects.len() == 1 { "" } else { "s" }
));
out
}
fn render_row(row: &ProjectRow) -> String {
let symbols = row
.symbol_count
.map_or_else(|| "?".to_string(), |n| n.to_string());
let files = row
.file_count
.map_or_else(|| "?".to_string(), |n| n.to_string());
let langs = if row.languages.is_empty() {
"—".to_string()
} else {
row.languages
.iter()
.take(3)
.map(|(l, _)| l.as_str())
.collect::<Vec<_>>()
.join(", ")
};
let when = row
.last_indexed
.map_or_else(|| "never".to_string(), cartog::time_fmt::format_relative);
let mut markers = Vec::new();
if row.markers.live {
markers.push("live".to_string());
}
if row.markers.stale_schema {
markers.push(match row.schema_version {
Some(v) => format!("stale-schema v{v}"),
None => "stale-schema".to_string(),
});
}
if row.markers.missing {
markers.push("missing".to_string());
}
if row.markers.embed_mismatch {
markers.push("embed-mismatch".to_string());
}
let marker_text = if markers.is_empty() {
String::new()
} else {
format!(" [{}]", markers.join(", "))
};
let description = match &row.description {
Some(d) => {
let suffix = if d.source == cartog_registry::DescriptionSource::Readme {
" (readme)"
} else {
""
};
format!(" {}{suffix}\n", truncate(&d.text, 96))
}
None => String::new(),
};
format!(
"{:<24} {:>9} symbols {:>6} files {:<24} {:>10}{}\n{} {}\n",
truncate(row.display_name(), 24),
symbols,
files,
truncate(&langs, 24),
when,
marker_text,
description,
row.db_path.display(),
)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let keep: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{keep}…")
}