use anyhow::Result;
use serde::Serialize;
use serde_json;
use crate::db::Database;
use crate::models::Issue;
use crate::utils::format_issue_id;
const fn status_icon(status: crate::models::IssueStatus) -> &'static str {
use crate::models::IssueStatus;
match status {
IssueStatus::Open => " ",
IssueStatus::Closed => "x",
IssueStatus::Archived => "?",
}
}
fn print_issue(issue: &Issue, indent: usize) {
let prefix = " ".repeat(indent);
let icon = status_icon(issue.status);
println!(
"{}[{}] {} {} - {}",
prefix,
icon,
format_issue_id(issue.id),
issue.priority,
issue.title
);
}
fn print_tree_recursive(
db: &Database,
parent_id: i64,
indent: usize,
status_filter: Option<&str>,
) -> Result<()> {
let subissues = db.get_subissues(parent_id)?;
for sub in subissues {
let dominated_by_filter = match status_filter {
Some("all") | None => false,
Some(filter) => sub.status.as_str() != filter,
};
if dominated_by_filter {
continue;
}
print_issue(&sub, indent);
print_tree_recursive(db, sub.id, indent + 1, status_filter)?;
}
Ok(())
}
#[derive(Serialize)]
struct TreeNode {
id: i64,
display_id: String,
title: String,
status: String,
priority: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
children: Vec<TreeNode>,
}
fn build_tree_node(db: &Database, issue: &Issue, status_filter: Option<&str>) -> Result<TreeNode> {
let subissues = db.get_subissues(issue.id)?;
let children: Vec<TreeNode> = subissues
.iter()
.filter(|sub| match status_filter {
Some("all") | None => true,
Some(filter) => sub.status.as_str() == filter,
})
.map(|sub| build_tree_node(db, sub, status_filter))
.collect::<Result<Vec<_>>>()?;
Ok(TreeNode {
id: issue.id,
display_id: format_issue_id(issue.id),
title: issue.title.clone(),
status: issue.status.to_string(),
priority: issue.priority.to_string(),
children,
})
}
pub fn run(db: &Database, status_filter: Option<&str>, json: bool) -> Result<()> {
let all_issues = db.list_issues(status_filter, None, None)?;
let top_level: Vec<_> = all_issues
.into_iter()
.filter(|i| i.parent_id.is_none())
.collect();
if json {
let nodes: Vec<TreeNode> = top_level
.iter()
.map(|issue| build_tree_node(db, issue, status_filter))
.collect::<Result<Vec<_>>>()?;
println!("{}", serde_json::to_string_pretty(&nodes)?);
return Ok(());
}
if top_level.is_empty() {
println!("No issues found.");
return Ok(());
}
for issue in top_level {
print_issue(&issue, 0);
print_tree_recursive(db, issue.id, 1, status_filter)?;
}
println!();
println!("Legend: [ ] open, [x] closed");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use tempfile::tempdir;
fn setup_test_db() -> (Database, tempfile::TempDir) {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path).unwrap();
(db, dir)
}
#[test]
fn test_status_icon_open() {
assert_eq!(status_icon(crate::models::IssueStatus::Open), " ");
}
#[test]
fn test_status_icon_closed() {
assert_eq!(status_icon(crate::models::IssueStatus::Closed), "x");
}
#[test]
fn test_status_icon_unknown() {
assert_eq!(status_icon(crate::models::IssueStatus::Archived), "?");
}
#[test]
fn test_run_empty() {
let (db, _dir) = setup_test_db();
run(&db, None, false).unwrap();
let issues = db.list_issues(None, None, None).unwrap();
assert!(issues.is_empty());
}
#[test]
fn test_run_single_issue() {
let (db, _dir) = setup_test_db();
let id = db.create_issue("Test issue", None, "medium").unwrap();
run(&db, None, false).unwrap();
let issues = db.list_issues(None, None, None).unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].id, id);
}
#[test]
fn test_run_with_hierarchy() {
let (db, _dir) = setup_test_db();
let parent = db.create_issue("Parent", None, "high").unwrap();
let c1 = db
.create_subissue(parent, "Child 1", None, "medium")
.unwrap();
let c2 = db.create_subissue(parent, "Child 2", None, "low").unwrap();
run(&db, None, false).unwrap();
let subs = db.get_subissues(parent).unwrap();
assert_eq!(subs.len(), 2);
assert!(subs.iter().any(|s| s.id == c1));
assert!(subs.iter().any(|s| s.id == c2));
}
#[test]
fn test_run_nested_hierarchy() {
let (db, _dir) = setup_test_db();
let grandparent = db.create_issue("Grandparent", None, "high").unwrap();
let parent = db
.create_subissue(grandparent, "Parent", None, "medium")
.unwrap();
let child = db.create_subissue(parent, "Child", None, "low").unwrap();
run(&db, None, false).unwrap();
let child_issue = db.get_issue(child).unwrap().unwrap();
assert_eq!(child_issue.parent_id, Some(parent));
let parent_issue = db.get_issue(parent).unwrap().unwrap();
assert_eq!(parent_issue.parent_id, Some(grandparent));
}
#[test]
fn test_run_with_status_filter() {
let (db, _dir) = setup_test_db();
let closed_id = db.create_issue("Closed issue", None, "medium").unwrap();
let open_id = db.create_issue("Open issue", None, "medium").unwrap();
db.close_issue(closed_id).unwrap();
run(&db, Some("open"), false).unwrap();
let open_issues = db.list_issues(Some("open"), None, None).unwrap();
assert_eq!(open_issues.len(), 1);
assert_eq!(open_issues[0].id, open_id);
}
#[test]
fn test_run_closed_filter() {
let (db, _dir) = setup_test_db();
let id = db.create_issue("Issue", None, "medium").unwrap();
db.close_issue(id).unwrap();
run(&db, Some("closed"), false).unwrap();
let closed = db.list_issues(Some("closed"), None, None).unwrap();
assert_eq!(closed.len(), 1);
assert_eq!(closed[0].id, id);
}
#[test]
fn test_run_all_filter() {
let (db, _dir) = setup_test_db();
db.create_issue("Open issue", None, "medium").unwrap();
let id = db.create_issue("Closed issue", None, "medium").unwrap();
db.close_issue(id).unwrap();
run(&db, Some("all"), false).unwrap();
let all = db.list_issues(Some("all"), None, None).unwrap();
assert_eq!(all.len(), 2);
}
proptest! {
#[test]
fn prop_run_never_panics(count in 0usize..5) {
let (db, _dir) = setup_test_db();
for i in 0..count {
db.create_issue(&format!("Issue {i}"), None, "medium").unwrap();
}
let result = run(&db, None, false);
prop_assert!(result.is_ok());
}
#[test]
fn prop_hierarchy_never_panics(depth in 1usize..4) {
let (db, _dir) = setup_test_db();
let mut parent_id = db.create_issue("Root", None, "high").unwrap();
for i in 0..depth {
parent_id = db.create_subissue(parent_id, &format!("Child {i}"), None, "medium").unwrap();
}
let result = run(&db, None, false);
prop_assert!(result.is_ok());
}
}
}