use std::collections::HashSet;
use crate::analyses::import_graph::{build_import_graph_seeded, graph_metrics};
use crate::complexity::Tier1Language;
use crate::facts::FactsDb;
use crate::facts::ingest::coverage::{
REASON_BLOB_READ, REASON_PARSE_ERROR, ScanCoverage, ScanOutcome,
};
use crate::repo::Repo;
use crate::{Options, Result};
pub const SAMPLE_POINTS: usize = 12;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ArchitectureTrendRow {
pub date: String,
pub rev: String,
pub files: u32,
pub propagation_cost: f64,
pub cycle_count: u32,
pub largest_cycle: u32,
}
pub(crate) fn sampled_commits(db: &FactsDb) -> Result<Vec<(String, String)>> {
let commits: Vec<(String, String)> = crate::analyses::query::query_map_collect(
db,
"SELECT rev, CAST(date AS TEXT) FROM commits ORDER BY date ASC, rowid DESC",
[],
"sampled-commits",
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
)?;
if commits.is_empty() {
return Ok(Vec::new());
}
let picks = evenly_spaced_indices(commits.len(), SAMPLE_POINTS);
Ok(picks.into_iter().map(|i| commits[i].clone()).collect())
}
#[tracing::instrument(name = "architecture-trend", skip_all)]
pub fn run_architecture_trend<R: Repo>(
db: &FactsDb,
repo: &R,
_opts: &Options,
) -> Result<Vec<ArchitectureTrendRow>> {
let samples = sampled_commits(db)?;
let mut rows = Vec::with_capacity(samples.len());
for (rev, ts) in &samples {
let graph = import_graph_at_rev(db, repo, rev, ts)?;
let m = graph_metrics(&graph);
rows.push(ArchitectureTrendRow {
date: ts.get(..10).unwrap_or(ts).to_string(),
rev: rev.chars().take(12).collect(),
files: u32::try_from(m.n).unwrap_or(u32::MAX),
propagation_cost: m.propagation_cost,
cycle_count: m.cycle_count,
largest_cycle: m.largest_cycle,
});
}
Ok(rows)
}
pub(crate) fn import_graph_at_rev<R: Repo>(
db: &FactsDb,
repo: &R,
rev: &str,
ts: &str,
) -> Result<crate::analyses::import_graph::ImportGraph> {
let live = live_paths_at(db, ts)?;
Ok(import_graph_from_live_paths(repo, rev, &live))
}
pub(crate) fn import_graph_from_live_paths<R: Repo>(
repo: &R,
rev: &str,
live: &[String],
) -> crate::analyses::import_graph::ImportGraph {
let seeds: Vec<String> = live
.iter()
.filter(|p| Tier1Language::from_path(p.as_str()).is_some())
.cloned()
.collect();
let edges = resolve_imports_at_rev(repo, rev, live);
build_import_graph_seeded(&seeds, &edges)
}
pub(crate) fn evenly_spaced_indices(len: usize, k: usize) -> Vec<usize> {
if len == 0 {
return Vec::new();
}
if len <= k {
return (0..len).collect();
}
let mut out = Vec::with_capacity(k);
for i in 0..k {
out.push(i * (len - 1) / (k - 1));
}
out.dedup();
out
}
pub(crate) fn live_paths_at(db: &FactsDb, ts: &str) -> Result<Vec<String>> {
crate::analyses::query::query_map_collect(
db,
"SELECT path FROM ( \
SELECT c.path, \
arg_max(c.change_type, ROW(commits.date, -commits.rowid)) AS change_type, \
MAX(ROW(commits.date, -commits.rowid)) AS last_seen \
FROM changes c \
INNER JOIN commits ON commits.rev = c.rev \
WHERE commits.date <= CAST(? AS TIMESTAMP) \
GROUP BY c.path \
) l \
WHERE l.change_type != 'deleted' \
AND NOT EXISTS ( \
SELECT 1 FROM changes r \
INNER JOIN commits rc ON rc.rev = r.rev \
WHERE r.rename_from = l.path \
AND r.change_type = 'renamed' \
AND rc.date <= CAST(? AS TIMESTAMP) \
AND ROW(rc.date, -rc.rowid) > l.last_seen \
)",
duckdb::params![ts, ts],
"architecture-trend live-paths",
|r| r.get::<_, String>(0),
)
}
fn classify_import_file(
rel: &str,
read: crate::Result<Option<Vec<u8>>>,
lang: crate::imports::ImportLanguage,
live_set: &HashSet<String>,
) -> ScanOutcome<Vec<(String, String)>> {
use crate::imports::{extract_imports, resolve_by_extension};
let code = match read {
Ok(Some(code)) => code,
Ok(None) => return ScanOutcome::NotCounted,
Err(e) => {
tracing::warn!("architecture-trend: blob read failed for {rel}: {e}");
return ScanOutcome::Lost(REASON_BLOB_READ);
}
};
if code.len() > crate::constants::DEFAULT_MAX_AST_FILE_BYTES {
return ScanOutcome::SkippedOversize;
}
let Ok(imports) = extract_imports(&code, lang) else {
tracing::warn!("architecture-trend: import parse failed for {rel}");
return ScanOutcome::Lost(REASON_PARSE_ERROR);
};
let mut out: Vec<(String, String)> = Vec::new();
for imp in imports {
if let Some(target_path) = resolve_by_extension(rel, &imp.target, live_set) {
out.push((rel.to_string(), target_path));
}
}
ScanOutcome::Scored(out)
}
fn resolve_imports_at_rev<R: Repo>(
repo: &R,
rev: &str,
live_paths: &[String],
) -> Vec<(String, String)> {
use crate::imports::ImportLanguage;
use rayon::prelude::*;
let live_set: HashSet<String> = live_paths.iter().cloned().collect();
let candidates: Vec<(String, ImportLanguage)> = live_paths
.iter()
.filter_map(|rel| {
let lang = ImportLanguage::from_path(std::path::Path::new(rel))?;
Some((rel.clone(), lang))
})
.collect();
let outcomes: Vec<ScanOutcome<Vec<(String, String)>>> = candidates
.into_par_iter()
.map_init(
|| repo.blob_reader_at(rev),
|reader, (rel, lang)| classify_import_file(&rel, reader.read(&rel), lang, &live_set),
)
.collect();
let coverage = ScanCoverage::tally(&outcomes);
coverage.warn_if_degraded("architecture-trend import", "import graph");
coverage.warn_if_mostly_oversize("architecture-trend import", "import graph");
outcomes
.into_iter()
.filter_map(|o| match o {
ScanOutcome::Scored(edges) => Some(edges),
_ => None,
})
.flatten()
.collect()
}
#[cfg(all(test, feature = "test-support"))]
mod tests {
use super::live_paths_at;
use crate::facts::FactsDb;
#[test]
fn an_unreadable_blob_is_a_counted_loss_not_a_silent_drop() {
use super::{ScanOutcome, classify_import_file};
use crate::imports::ImportLanguage;
use std::collections::HashSet;
let live: HashSet<String> = HashSet::new();
let err = Err(crate::CodeLoreError::Repo("simulated odb failure".into()));
let outcome = classify_import_file("src/a.rs", err, ImportLanguage::Rust, &live);
assert!(
matches!(outcome, ScanOutcome::Lost(_)),
"a failed blob read must be counted as lost — the trend chart seeds \
its node count from live paths, so an uncounted failure keeps the \
denominator, drops edges, and reads as improving health"
);
let absent = classify_import_file("src/a.rs", Ok(None), ImportLanguage::Rust, &live);
assert!(matches!(absent, ScanOutcome::NotCounted));
let empty = classify_import_file(
"src/a.rs",
Ok(Some(b"fn main() {}\n".to_vec())),
ImportLanguage::Rust,
&live,
);
assert!(
matches!(empty, ScanOutcome::Scored(ref e) if e.is_empty()),
"a file with no imports was still reached and parsed — covered, not uncounted"
);
}
fn seed_commit(db: &FactsDb, rev: &str, day: u32) {
db.execute_batch(&format!(
"INSERT INTO commits (rev, author_email, author_name, committer_email, \
canonical_author, date, committer_date, message, is_merge, parent_count) \
VALUES ('{rev}', 'a@x', 'A', 'a@x', 'a@x', \
'2026-03-{day:02} 12:00:00', '2026-03-{day:02} 12:00:00', 'm', false, 1)"
))
.expect("seed commit");
}
#[test]
fn renamed_away_paths_are_dead_after_the_rename_and_live_before_it() {
let db = FactsDb::new_in_memory().expect("db");
seed_commit(&db, "c3", 5);
seed_commit(&db, "c2", 3);
seed_commit(&db, "c1", 1);
for stmt in [
"INSERT INTO changes VALUES ('c1', 'a.rs', 'added', NULL, 5, 0)",
"INSERT INTO changes VALUES ('c2', 'b.rs', 'renamed', 'a.rs', 0, 0)",
"INSERT INTO changes VALUES ('c3', 'a.rs', 'added', NULL, 7, 0)",
] {
db.execute_batch(stmt).expect("seed");
}
let at = |ts: &str| {
let mut v = live_paths_at(&db, ts).expect("live_paths_at");
v.sort();
v
};
assert_eq!(
at("2026-03-02 00:00:00"),
vec!["a.rs"],
"before the rename the source is live under its era name"
);
assert_eq!(
at("2026-03-04 00:00:00"),
vec!["b.rs"],
"after the rename only the new name is live — the retired source must not linger"
);
assert_eq!(
at("2026-03-06 00:00:00"),
vec!["a.rs", "b.rs"],
"a recycled name is a NEW live file alongside the rename target"
);
}
}