use std::path::Path;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
pub fn run(project: &Path, book_name: Option<&str>) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
let h = Hierarchy::load(&store)?;
let root_id = resolve_scope(&h, book_name)?;
let candidates: Vec<&Node> = match root_id {
Some(id) => h
.collect_subtree(id)
.into_iter()
.filter_map(|nid| h.get(nid))
.filter(|n| n.kind == NodeKind::Paragraph)
.collect(),
None => h
.flatten()
.into_iter()
.map(|(n, _)| n)
.filter(|n| n.kind == NodeKind::Paragraph)
.filter(|n| !is_in_system_book(&h, n))
.collect(),
};
if candidates.is_empty() {
println!("(no paragraphs)");
return Ok(());
}
let title_w = candidates
.iter()
.map(|n| display_width(&n.title).min(50))
.max()
.unwrap_or(20)
.max(20);
let slug_w = candidates
.iter()
.map(|n| display_width(&n.slug).min(30))
.max()
.unwrap_or(10)
.max(10);
let status_w = 6; let words_w = 7;
let target_w = 8;
let age_w = 10;
println!(
"{:<title_w$} {:<slug_w$} {:<status_w$} {:>words_w$} {:>target_w$} {:>age_w$}",
"TITLE",
"SLUG",
"STATUS",
"WORDS",
"TARGET",
"AGE",
title_w = title_w,
slug_w = slug_w,
status_w = status_w,
words_w = words_w,
target_w = target_w,
age_w = age_w,
);
println!(
"{}",
"─".repeat(title_w + slug_w + status_w + words_w + target_w + age_w + 10)
);
let mut total_words: u64 = 0;
let mut total_target: i64 = 0;
let mut at_or_above_target: usize = 0;
let mut with_target: usize = 0;
let mut by_status: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for n in &candidates {
total_words += n.word_count;
let target_str = match n.target_words {
Some(t) if t > 0 => {
with_target += 1;
total_target += t as i64;
let pct = (n.word_count as i64 * 100 / t as i64).clamp(0, 999);
if pct >= 100 {
at_or_above_target += 1;
}
format!("{pct}%")
}
_ => "—".to_string(),
};
let status = n.status.as_deref().unwrap_or("—").to_string();
*by_status.entry(status.clone()).or_insert(0) += 1;
let age = humantime_short(
chrono::Utc::now().signed_duration_since(n.modified_at).num_seconds().max(0) as u64,
);
println!(
"{:<title_w$} {:<slug_w$} {:<status_w$} {:>words_w$} {:>target_w$} {:>age_w$}",
truncate(&n.title, title_w),
truncate(&n.slug, slug_w),
truncate(&status, status_w),
n.word_count,
target_str,
age,
title_w = title_w,
slug_w = slug_w,
status_w = status_w,
words_w = words_w,
target_w = target_w,
age_w = age_w,
);
}
println!();
println!("Summary");
println!(" paragraphs: {}", candidates.len());
println!(" total words: {}", total_words);
if with_target > 0 {
println!(
" target words: {} ({}/{} paragraphs at-or-above target)",
total_target, at_or_above_target, with_target,
);
}
println!(" by status:");
for (k, v) in &by_status {
println!(" {:<8} {}", k, v);
}
Ok(())
}
fn resolve_scope(
h: &Hierarchy,
book_name: Option<&str>,
) -> Result<Option<uuid::Uuid>> {
let user_books: Vec<&Node> = h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
.collect();
match book_name {
Some(name) => {
let needle = name.trim().to_ascii_lowercase();
let pick = user_books.iter().copied().find(|b| {
b.title.to_ascii_lowercase() == needle
|| b.slug.to_ascii_lowercase() == needle
});
match pick {
Some(book) => Ok(Some(book.id)),
None => {
let listing = user_books
.iter()
.map(|b| format!("`{}`", b.title))
.collect::<Vec<_>>()
.join(", ");
Err(Error::Store(format!(
"stats: no book matches `--book-name {name}`. Available: {listing}"
)))
}
}
}
None => {
if user_books.len() > 1 {
let listing = user_books
.iter()
.map(|b| format!("`{}`", b.title))
.collect::<Vec<_>>()
.join(", ");
return Err(Error::Store(format!(
"stats: project has {} user books — pass --book-name <name>. Available: {listing}",
user_books.len(),
)));
}
Ok(user_books.first().map(|b| b.id))
}
}
}
fn is_in_system_book(h: &Hierarchy, n: &Node) -> bool {
h.ancestors(n)
.into_iter()
.any(|a| a.kind == NodeKind::Book && a.system_tag.is_some())
}
fn display_width(s: &str) -> usize {
s.chars().count()
}
fn truncate(s: &str, max: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
return s.to_string();
}
let cut: String = chars.iter().take(max.saturating_sub(1)).collect();
format!("{cut}…")
}
fn humantime_short(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86_400)
}
}