use crate::sdd::project::config::load_required_config;
use crate::sdd::shared::constants::{LLMANSPEC_DIR_NAME, SPEC_FILE};
use crate::sdd::shared::discovery::{list_changes, list_specs};
use crate::sdd::shared::tasks;
use crate::sdd::spec::parser::parse_spec;
use crate::sdd::spec::validation::{ChangeStage, determine_stage};
use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::cmp::{Reverse, max};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ListArgs {
pub specs: bool,
pub changes: bool,
pub sort: String,
pub json: bool,
pub compact_json: bool,
pub no_interactive: bool,
}
#[derive(Debug, Serialize)]
struct ChangeJson {
name: String,
stage: String,
#[serde(rename = "completedTasks")]
completed_tasks: usize,
#[serde(rename = "totalTasks")]
total_tasks: usize,
#[serde(rename = "lastModified")]
last_modified: String,
status: String,
}
#[derive(Debug)]
struct ChangeInfo {
name: String,
stage: ChangeStage,
completed_tasks: usize,
total_tasks: usize,
last_modified: DateTime<Utc>,
}
pub fn run(args: ListArgs) -> Result<()> {
if args.specs && args.changes {
return Err(anyhow!(t!("sdd.list.conflicting_flags")));
}
let root = Path::new(".");
let _changes_requested = args.changes; let mode = if args.specs { "specs" } else { "changes" };
if mode == "changes" {
list_changes_mode(root, &args)
} else {
list_specs_mode(root, &args)
}
}
#[allow(clippy::items_after_test_module)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_conflicting_flags() {
let result = run(ListArgs {
specs: true,
changes: true,
sort: "recent".to_string(),
json: false,
compact_json: false,
no_interactive: false,
});
assert!(result.is_err());
}
}
fn list_changes_mode(root: &Path, args: &ListArgs) -> Result<()> {
let changes_dir = root.join(LLMANSPEC_DIR_NAME).join("changes");
if !changes_dir.exists() {
return Err(anyhow!(t!("sdd.list.no_changes_dir")));
}
let change_ids = list_changes(root)?;
if change_ids.is_empty() {
if args.json {
print_json(&serde_json::json!({"changes": []}), args.compact_json)?;
} else {
println!("{}", t!("sdd.list.no_active_changes"));
}
return Ok(());
}
let mut changes = Vec::new();
for change in change_ids {
let (completed, total) = task_progress(&changes_dir, &change)?;
let last_modified = last_modified(&changes_dir.join(&change))?;
let stage = determine_stage(&changes_dir.join(&change));
changes.push(ChangeInfo {
name: change,
stage,
completed_tasks: completed,
total_tasks: total,
last_modified,
});
}
let sort_by_name = args.sort == "name";
if sort_by_name {
changes.sort_by(|a, b| natural_cmp(&a.name, &b.name));
} else {
changes.sort_by_key(|change| Reverse(change.last_modified));
}
if args.json {
let json_output: Vec<ChangeJson> = changes
.iter()
.map(|c| ChangeJson {
name: c.name.clone(),
stage: c.stage.as_str().to_string(),
completed_tasks: c.completed_tasks,
total_tasks: c.total_tasks,
last_modified: c.last_modified.to_rfc3339(),
status: status_key(c.completed_tasks, c.total_tasks).to_string(),
})
.collect();
print_json(
&serde_json::json!({"changes": json_output}),
args.compact_json,
)?;
return Ok(());
}
println!("{}", t!("sdd.list.changes_header"));
let name_width = changes.iter().map(|c| c.name.len()).fold(0, max);
for change in changes {
let padded = format!("{:<width$}", change.name, width = name_width);
let stage = format!("{:<10}", change.stage.as_str());
let status = format_task_status(change.completed_tasks, change.total_tasks);
let time_ago = format_relative_time(change.last_modified);
println!(" {} {} {:<12} {}", padded, stage, status, time_ago);
}
Ok(())
}
fn list_specs_mode(root: &Path, args: &ListArgs) -> Result<()> {
let llmanspec_dir = root.join(LLMANSPEC_DIR_NAME);
let _config = load_required_config(&llmanspec_dir)?;
let specs_dir = root.join(LLMANSPEC_DIR_NAME).join("specs");
if !specs_dir.exists() {
if args.json {
print_json(&serde_json::json!([]), args.compact_json)?;
} else {
println!("{}", t!("sdd.list.no_specs"));
}
return Ok(());
}
let spec_ids = list_specs(root)?;
if spec_ids.is_empty() {
if args.json {
print_json(&serde_json::json!([]), args.compact_json)?;
} else {
println!("{}", t!("sdd.list.no_specs"));
}
return Ok(());
}
let mut specs = Vec::new();
for id in spec_ids {
let spec_path = specs_dir.join(&id).join(SPEC_FILE);
let content = fs::read_to_string(&spec_path)
.map_err(|err| anyhow!("failed to read spec {}: {}", spec_path.display(), err))?;
let spec =
parse_spec(&content, &id).map_err(|err| anyhow!("{}: {}", spec_path.display(), err))?;
specs.push((id, spec.requirements.len()));
}
specs.sort_by(|a, b| natural_cmp(&a.0, &b.0));
if args.json {
let json_output: Vec<_> = specs
.iter()
.map(|(id, count)| {
serde_json::json!({
"id": id,
"title": id,
"requirementCount": count
})
})
.collect();
print_json(&serde_json::json!(json_output), args.compact_json)?;
return Ok(());
}
println!("{}", t!("sdd.list.specs_header"));
let name_width = specs.iter().map(|s| s.0.len()).fold(0, max);
for (id, count) in specs {
let padded = format!("{:<width$}", id, width = name_width);
println!(" {} requirements {}", padded, count);
}
Ok(())
}
fn print_json(value: &serde_json::Value, compact: bool) -> Result<()> {
if compact {
println!("{}", serde_json::to_string(value)?);
} else {
println!("{}", serde_json::to_string_pretty(value)?);
}
Ok(())
}
fn status_key(completed: usize, total: usize) -> &'static str {
if total == 0 {
return "no-tasks";
}
if completed == total {
return "complete";
}
"in-progress"
}
fn task_progress(changes_dir: &Path, change_name: &str) -> Result<(usize, usize)> {
let tasks_path = changes_dir.join(change_name).join("tasks.md");
match tasks::parse_tasks_file(&tasks_path)? {
Some(report) => Ok((report.completed, report.total())),
None => Ok((0, 0)),
}
}
fn format_task_status(completed: usize, total: usize) -> String {
if total == 0 {
return t!("sdd.list.no_tasks_status").to_string();
}
if completed == total {
return t!("sdd.list.complete_status").to_string();
}
format!("{}/{} tasks", completed, total)
}
fn last_modified(dir: &Path) -> Result<DateTime<Utc>> {
let mut latest: Option<DateTime<Utc>> = None;
let mut stack = vec![dir.to_path_buf()];
while let Some(path) = stack.pop() {
for entry in fs::read_dir(path)? {
let entry = entry?;
let meta = entry.metadata()?;
if meta.is_dir() {
stack.push(entry.path());
} else {
let modified: DateTime<Utc> = meta.modified()?.into();
if latest.map(|l| modified > l).unwrap_or(true) {
latest = Some(modified);
}
}
}
}
Ok(latest.unwrap_or_else(Utc::now))
}
fn format_relative_time(time: DateTime<Utc>) -> String {
let now = Utc::now();
let diff = now.signed_duration_since(time);
if diff.num_days() > 30 {
return time.format("%Y-%m-%d").to_string();
}
if diff.num_days() > 0 {
return format!("{}d ago", diff.num_days());
}
if diff.num_hours() > 0 {
return format!("{}h ago", diff.num_hours());
}
if diff.num_minutes() > 0 {
return format!("{}m ago", diff.num_minutes());
}
t!("sdd.list.just_now").to_string()
}
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let mut ai = a.chars().peekable();
let mut bi = b.chars().peekable();
loop {
let ac = ai.next();
let bc = bi.next();
match (ac, bc) {
(None, None) => return std::cmp::Ordering::Equal,
(None, _) => return std::cmp::Ordering::Less,
(_, None) => return std::cmp::Ordering::Greater,
(Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => {
let num_ord = compare_number_runs(&mut ai, &mut bi, x, y);
if num_ord != std::cmp::Ordering::Equal {
return num_ord;
}
}
(Some(x), Some(y)) if x != y => return x.cmp(&y),
_ => continue,
}
}
}
fn compare_number_runs<I: Iterator<Item = char>>(
ai: &mut std::iter::Peekable<I>,
bi: &mut std::iter::Peekable<I>,
x: char,
y: char,
) -> std::cmp::Ordering {
let mut an = (x as u32).wrapping_sub(b'0' as u32);
let mut bn = (y as u32).wrapping_sub(b'0' as u32);
while let Some(&c) = ai.peek() {
if c.is_ascii_digit() {
an = an * 10 + (c as u32).wrapping_sub(b'0' as u32);
ai.next();
} else {
break;
}
}
while let Some(&c) = bi.peek() {
if c.is_ascii_digit() {
bn = bn * 10 + (c as u32).wrapping_sub(b'0' as u32);
bi.next();
} else {
break;
}
}
an.cmp(&bn)
}
#[cfg(test)]
mod sort_tests {
use super::*;
#[test]
fn natural_sort_digits() {
let mut items = vec![
"c100-xxx".to_string(),
"c10-xxx".to_string(),
"c3-xxx".to_string(),
"c20-xxx".to_string(),
];
items.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(items, vec!["c3-xxx", "c10-xxx", "c20-xxx", "c100-xxx"]);
}
#[test]
fn natural_sort_mixed() {
let mut items = vec!["c05-init".to_string(), "c5-short".to_string()];
items.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(items, vec!["c05-init", "c5-short"]);
}
#[test]
fn natural_sort_pure_alpha() {
let mut items = vec!["beta".to_string(), "alpha".to_string(), "gamma".to_string()];
items.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(items, vec!["alpha", "beta", "gamma"]);
}
}