use anyhow::Result;
use chrono::{DateTime, Local};
use colored::*;
use design::doc::DocState;
use design::index::DocumentIndex;
use design::state::StateManager;
use design::theme;
use oxur_cli::table::TableStyleConfig;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tabled::{builder::Builder, Table, Tabled};
use walkdir::WalkDir;
#[derive(Tabled)]
struct DocumentRow {
number: String,
title: String,
state: String,
}
#[derive(Tabled)]
struct RemovedDocRow {
number: String,
title: String,
removed: String,
deleted: String,
location: String,
}
#[derive(Tabled)]
struct DevDocRow {
filename: String,
updated: String,
}
pub const DEFAULT_LIMIT: usize = 20;
pub struct ListFilters {
pub state: Option<String>,
pub component: Option<String>,
pub tags: Vec<String>,
pub limit: usize,
pub all: bool,
}
impl Default for ListFilters {
fn default() -> Self {
Self { state: None, component: None, tags: Vec::new(), limit: DEFAULT_LIMIT, all: false }
}
}
impl ListFilters {
pub fn effective_limit(&self) -> Option<usize> {
if self.all {
None
} else {
Some(self.limit)
}
}
}
fn apply_state_cell_colors(
table: &mut Table,
docs: &[&design::doc::DesignDoc],
config: &TableStyleConfig,
) {
let row_bg_colors = oxur_cli::table::helpers::parse_row_bg_colors(config);
for (i, doc) in docs.iter().enumerate() {
let row_idx = 2 + i;
if let Some(fg_color) =
oxur_cli::table::helpers::state_to_fg_color(doc.metadata.state.as_str())
{
let bg_color = oxur_cli::table::helpers::get_data_row_bg_color(i, &row_bg_colors);
oxur_cli::table::helpers::apply_cell_color(table, row_idx, 2, fg_color, bg_color);
}
}
}
#[allow(dead_code)]
pub fn list_documents(
index: &DocumentIndex,
state_filter: Option<String>,
verbose: bool,
) -> Result<()> {
let filters = ListFilters { state: state_filter, ..Default::default() };
list_documents_impl(index, None, &filters, verbose, false, false)
}
pub fn list_documents_with_state(
index: &DocumentIndex,
state_mgr: Option<&StateManager>,
filters: &ListFilters,
verbose: bool,
removed: bool,
dev: bool,
) -> Result<()> {
list_documents_impl(index, state_mgr, filters, verbose, removed, dev)
}
fn list_documents_impl(
index: &DocumentIndex,
state_mgr: Option<&StateManager>,
filters: &ListFilters,
verbose: bool,
removed: bool,
dev: bool,
) -> Result<()> {
if dev {
return list_dev_documents(verbose, filters.effective_limit());
}
if removed {
if let Some(mgr) = state_mgr {
return list_removed_documents(mgr, verbose, filters.effective_limit());
} else {
eprintln!(
"{} Cannot list removed documents without state manager",
"ERROR:".red().bold()
);
return Ok(());
}
}
let mut docs = if let Some(state_str) = &filters.state {
match DocState::from_str_flexible(state_str) {
Some(state) => index.by_state(state),
None => {
eprintln!("{} Unknown state: {}", "ERROR:".red().bold(), state_str);
eprintln!("Valid states: {}", DocState::all_state_names().join(", "));
return Ok(());
}
}
} else {
index.all()
};
if let Some(component) = &filters.component {
docs.retain(|doc| doc.metadata.component.as_ref().map(|c| c == component).unwrap_or(false));
}
if !filters.tags.is_empty() {
docs.retain(|doc| {
filters
.tags
.iter()
.any(|filter_tag| doc.metadata.tags.iter().any(|doc_tag| doc_tag == filter_tag))
});
}
let total_count = docs.len();
let effective_limit = filters.effective_limit();
let is_truncated = effective_limit.map(|limit| total_count > limit).unwrap_or(false);
if let Some(limit) = effective_limit {
docs.truncate(limit);
}
if verbose {
println!("\n{}", "Design Documents".bold().underline());
println!();
for doc in &docs {
let state = doc.metadata.state.as_str();
println!(
"{} {} [{}]",
theme::doc_number(doc.metadata.number),
doc.metadata.title,
theme::state_badge(state)
);
println!(" Author: {}", doc.metadata.author);
println!(" Created: {} | Updated: {}", doc.metadata.created, doc.metadata.updated);
if let Some(supersedes) = doc.metadata.supersedes {
println!(" Supersedes: {:04}", supersedes);
}
if let Some(superseded_by) = doc.metadata.superseded_by {
println!(" Superseded by: {:04}", superseded_by);
}
println!();
}
if is_truncated {
println!(
"Showing {} of {} documents (use --all to see all)\n",
docs.len(),
total_count
);
} else {
println!("Total: {} documents\n", total_count);
}
} else {
let mut builder = Builder::default();
builder.push_record(["DESIGN", "DOCUMENTS", ""]);
builder.push_record(["Number", "Title", "State"]);
for doc in &docs {
builder.push_record([
&format!(" {:04}", doc.metadata.number),
&format!(" {:} ", doc.metadata.title.as_str()),
&format!(" {:<14}", doc.metadata.state.as_str()),
]);
}
let total_text = if is_truncated {
format!("{} of {} (--all for more)", docs.len(), total_count)
} else {
format!("{} documents", total_count)
};
builder.push_record(["Total:", &total_text, ""]);
let mut table = builder.build();
let config = TableStyleConfig::default();
config.apply_to_table::<DocumentRow>(&mut table);
apply_state_cell_colors(&mut table, &docs, &config);
println!();
println!("{}", table);
println!();
}
Ok(())
}
fn list_removed_documents(
state_mgr: &StateManager,
verbose: bool,
limit: Option<usize>,
) -> Result<()> {
let mut removed_docs: Vec<_> = state_mgr
.state()
.all()
.into_iter()
.filter(|d| {
d.metadata.state == DocState::Removed || d.metadata.state == DocState::Overwritten
})
.collect();
if removed_docs.is_empty() {
println!();
println!("{}", "Removed Documents".cyan().bold());
println!();
println!(" {}", "No removed documents found.".yellow());
println!();
return Ok(());
}
let total_count = removed_docs.len();
let is_truncated = limit.map(|l| total_count > l).unwrap_or(false);
let mut in_dustbin = 0;
let mut deleted = 0;
for doc in &removed_docs {
let file_path = state_mgr.docs_dir().join(&doc.path);
if file_path.exists() {
in_dustbin += 1;
} else {
deleted += 1;
}
}
if let Some(l) = limit {
removed_docs.truncate(l);
}
let mut builder = Builder::default();
builder.push_record(["REMOVED DOCUMENTS", "", "", "", ""]);
if verbose {
builder.push_record(["Number", "Title", "Removed", "Deleted", "Dustbin Location"]);
} else {
builder.push_record(["Number", "Title", "Removed", "Deleted", ""]);
}
for doc in &removed_docs {
let number_str = format!("{:04}", doc.metadata.number);
let title_truncated = if doc.metadata.title.len() > (if verbose { 33 } else { 38 }) {
format!("{}...", &doc.metadata.title[..(if verbose { 30 } else { 35 })])
} else {
doc.metadata.title.clone()
};
let file_path = state_mgr.docs_dir().join(&doc.path);
let file_exists = file_path.exists();
let location = if verbose {
if file_exists {
doc.path.clone()
} else {
"(file not found)".to_string()
}
} else {
String::new()
};
let deleted_str = if file_exists { "false".to_string() } else { "true".to_string() };
builder.push_record([
&number_str, &title_truncated,
&doc.metadata.updated.to_string(), &deleted_str, &location,
]);
}
let total_text = if is_truncated {
format!("{} of {} (--all for more)", removed_docs.len(), total_count)
} else {
format!("{} removed ({} in dustbin, {} deleted)", total_count, in_dustbin, deleted)
};
builder.push_record(["Total:", &total_text, "", "", ""]);
let mut table = builder.build();
let config = TableStyleConfig::default();
config.apply_to_table::<RemovedDocRow>(&mut table);
println!();
println!("{}", table);
println!();
Ok(())
}
fn has_valid_prefix(filename: &str) -> bool {
if filename.len() < 5 {
return false;
}
let chars: Vec<char> = filename.chars().collect();
chars[0].is_numeric()
&& chars[1].is_numeric()
&& chars[2].is_numeric()
&& chars[3].is_numeric()
&& chars.get(4) == Some(&'-')
}
fn extract_prefix_number(path: &Path) -> u32 {
path.file_name()
.and_then(|s| s.to_str())
.and_then(|s| s.get(0..4))
.and_then(|prefix| prefix.parse::<u32>().ok())
.unwrap_or(0)
}
fn format_mtime(mtime: &SystemTime) -> String {
let datetime: DateTime<Local> = (*mtime).into();
datetime.format("%Y-%m-%d %H:%M").to_string()
}
fn get_relative_path_from(path: &Path, from: &Path) -> String {
match path.strip_prefix(from) {
Ok(rel) => rel.to_string_lossy().to_string(),
Err(_) => path.to_string_lossy().to_string(),
}
}
fn filter_dev_file(path: &Path) -> Result<Option<(PathBuf, fs::Metadata)>> {
if path.extension().and_then(|s| s.to_str()) != Some("md") {
return Ok(None);
}
let filename = match path.file_name().and_then(|s| s.to_str()) {
Some(f) => f,
None => return Ok(None),
};
if !has_valid_prefix(filename) {
return Ok(None);
}
let metadata = fs::metadata(path)?;
Ok(Some((path.to_path_buf(), metadata)))
}
fn collect_dev_docs(dir: &Path, recursive: bool) -> Result<Vec<(PathBuf, fs::Metadata)>> {
let mut docs = Vec::new();
if recursive {
for entry in WalkDir::new(dir).min_depth(1).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
if let Some(doc) = filter_dev_file(entry.path())? {
docs.push(doc);
}
}
}
} else {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(doc) = filter_dev_file(&path)? {
docs.push(doc);
}
}
}
}
docs.sort_by(|a, b| {
let a_num = extract_prefix_number(&a.0);
let b_num = extract_prefix_number(&b.0);
b_num.cmp(&a_num) });
Ok(docs)
}
fn collect_subdirectories(dir: &Path) -> Result<Vec<String>> {
let mut subdirs = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
if !name.starts_with('.') {
subdirs.push(name.to_string());
}
}
}
}
subdirs.sort();
Ok(subdirs)
}
fn print_dev_table(
table_name: &str,
docs: &[(PathBuf, fs::Metadata)],
invocation_dir: &Path,
_verbose: bool,
limit: Option<usize>,
) -> Result<()> {
let total_count = docs.len();
let is_truncated = limit.map(|l| total_count > l).unwrap_or(false);
let display_docs: &[(PathBuf, fs::Metadata)] = if let Some(l) = limit {
if docs.len() > l {
&docs[..l]
} else {
docs
}
} else {
docs
};
let mut builder = Builder::default();
let title = table_name.to_uppercase();
builder.push_record([&title, ""]);
builder.push_record(["Filename", "Updated"]);
for (path, metadata) in display_docs {
let rel_path = get_relative_path_from(path, invocation_dir);
let mtime_str = format_mtime(&metadata.modified()?);
builder.push_record([&format!(" {} ", rel_path), &format!(" {} ", mtime_str)]);
}
let total_text = if is_truncated {
format!("{} of {} (--all for more)", display_docs.len(), total_count)
} else if total_count == 1 {
"1 document".to_string()
} else {
format!("{} documents", total_count)
};
builder.push_record(["Total:", &total_text]);
let mut table = builder.build();
let config = TableStyleConfig::default();
config.apply_to_table::<DevDocRow>(&mut table);
println!();
println!("{}", table);
println!();
Ok(())
}
fn list_dev_documents(_verbose: bool, limit: Option<usize>) -> Result<()> {
let config = design::config::Config::load(None)?;
let invocation_dir = env::current_dir()?;
let dev_dir = if config.dev_directory.is_relative() {
if let Some(repo_root) = design::git::get_repo_root() {
repo_root.join(&config.dev_directory)
} else {
invocation_dir.join(&config.dev_directory)
}
} else {
config.dev_directory.clone()
};
if !dev_dir.exists() {
eprintln!("{} Dev directory not found: {}", "ERROR:".red().bold(), dev_dir.display());
return Ok(());
}
let dev_root_docs = collect_dev_docs(&dev_dir, false)?;
let subdirs = collect_subdirectories(&dev_dir)?;
if !dev_root_docs.is_empty() {
print_dev_table("dev", &dev_root_docs, &invocation_dir, _verbose, limit)?;
}
for subdir_name in subdirs {
let subdir_path = dev_dir.join(&subdir_name);
let docs = collect_dev_docs(&subdir_path, true)?;
if !docs.is_empty() {
print_dev_table(&subdir_name, &docs, &invocation_dir, _verbose, limit)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use design::doc::DocMetadata;
use design::index::DocumentIndex;
use design::state::{DocumentRecord, DocumentState};
use tempfile::TempDir;
fn create_test_index() -> DocumentIndex {
let temp = TempDir::new().unwrap();
let mut state = DocumentState::new();
for (num, title, doc_state) in [
(1, "First Doc", DocState::Draft),
(2, "Second Doc", DocState::Final),
(3, "Third Doc", DocState::Draft),
(4, "Fourth Doc", DocState::Accepted),
] {
let meta = DocMetadata {
number: num,
title: title.to_string(),
author: "Test Author".to_string(),
component: None,
tags: Vec::new(),
created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
updated: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
state: doc_state,
supersedes: None,
superseded_by: None,
version: "1.0".to_string(),
};
state.upsert(
num,
DocumentRecord {
metadata: meta,
path: format!("{:04}-test.md", num),
checksum: "abc123".to_string(),
file_size: 100,
modified: chrono::Utc::now(),
},
);
}
DocumentIndex::from_state(&state, temp.path()).unwrap()
}
#[test]
fn test_list_all_documents() {
let index = create_test_index();
let result = list_documents(&index, None, false);
assert!(result.is_ok());
}
#[test]
fn test_list_with_valid_state_filter() {
let index = create_test_index();
let result = list_documents(&index, Some("Draft".to_string()), false);
assert!(result.is_ok());
}
#[test]
fn test_list_with_state_filter_case_insensitive() {
let index = create_test_index();
let result = list_documents(&index, Some("draft".to_string()), false);
assert!(result.is_ok());
}
#[test]
fn test_list_with_invalid_state_filter() {
let index = create_test_index();
let result = list_documents(&index, Some("InvalidState".to_string()), false);
assert!(result.is_ok());
}
#[test]
fn test_list_verbose_mode() {
let index = create_test_index();
let result = list_documents(&index, None, true);
assert!(result.is_ok());
}
#[test]
fn test_list_verbose_with_filter() {
let index = create_test_index();
let result = list_documents(&index, Some("Final".to_string()), true);
assert!(result.is_ok());
}
#[test]
fn test_list_empty_index() {
let temp = TempDir::new().unwrap();
let index = DocumentIndex::new(temp.path()).unwrap();
let result = list_documents(&index, None, false);
assert!(result.is_ok());
}
#[test]
fn test_list_all_state_types() {
let index = create_test_index();
for state in DocState::all_states() {
let result = list_documents(&index, Some(state.as_str().to_string()), false);
assert!(result.is_ok(), "Failed for state: {}", state.as_str());
}
}
fn create_test_state_manager_with_removed() -> (StateManager, TempDir) {
use std::fs;
let temp = TempDir::new().unwrap();
let docs_dir = temp.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let mut state_mgr = StateManager::new(&docs_dir).unwrap();
for (num, title, doc_state) in
[(1, "Active Doc", DocState::Active), (2, "Draft Doc", DocState::Draft)]
{
let meta = DocMetadata {
number: num,
title: title.to_string(),
author: "Test Author".to_string(),
component: None,
tags: Vec::new(),
created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
updated: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
state: doc_state,
supersedes: None,
superseded_by: None,
version: "1.0".to_string(),
};
state_mgr.state_mut().upsert(
num,
DocumentRecord {
metadata: meta,
path: format!(
"{}/{:04}-{}.md",
doc_state.directory(),
num,
title.to_lowercase().replace(' ', "-")
),
checksum: "abc123".to_string(),
file_size: 100,
modified: chrono::Utc::now(),
},
);
}
for (num, title, doc_state) in
[(3, "Removed Doc", DocState::Removed), (4, "Overwritten Doc", DocState::Overwritten)]
{
let meta = DocMetadata {
number: num,
title: title.to_string(),
author: "Test Author".to_string(),
component: None,
tags: Vec::new(),
created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
updated: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
state: doc_state,
supersedes: None,
superseded_by: None,
version: "1.0".to_string(),
};
let path = format!(
"{}/{:04}-{}.md",
doc_state.directory(),
num,
title.to_lowercase().replace(' ', "-")
);
state_mgr.state_mut().upsert(
num,
DocumentRecord {
metadata: meta,
path,
checksum: "abc123".to_string(),
file_size: 100,
modified: chrono::Utc::now(),
},
);
}
(state_mgr, temp)
}
#[test]
fn test_list_documents_with_state_no_removed() {
let (state_mgr, _temp) = create_test_state_manager_with_removed();
let index = DocumentIndex::from_state(state_mgr.state(), state_mgr.docs_dir()).unwrap();
let result = list_documents_with_state(
&index,
Some(&state_mgr),
&ListFilters::default(),
false,
false,
false,
);
assert!(result.is_ok());
}
#[test]
fn test_list_documents_with_state_removed_flag() {
let (state_mgr, _temp) = create_test_state_manager_with_removed();
let index = DocumentIndex::from_state(state_mgr.state(), state_mgr.docs_dir()).unwrap();
let result = list_documents_with_state(
&index,
Some(&state_mgr),
&ListFilters::default(),
false,
true,
false,
);
assert!(result.is_ok());
}
#[test]
fn test_list_documents_with_state_removed_verbose() {
let (state_mgr, _temp) = create_test_state_manager_with_removed();
let index = DocumentIndex::from_state(state_mgr.state(), state_mgr.docs_dir()).unwrap();
let result = list_documents_with_state(
&index,
Some(&state_mgr),
&ListFilters::default(),
true,
true,
false,
);
assert!(result.is_ok());
}
#[test]
fn test_list_documents_with_state_removed_no_state_mgr() {
let index = create_test_index();
let result =
list_documents_with_state(&index, None, &ListFilters::default(), false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_list_removed_documents_empty() {
use std::fs;
let temp = TempDir::new().unwrap();
let docs_dir = temp.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let state_mgr = StateManager::new(&docs_dir).unwrap();
let result = list_removed_documents(&state_mgr, false, None);
assert!(result.is_ok());
}
#[test]
fn test_list_removed_documents_with_files() {
use std::fs;
let temp = TempDir::new().unwrap();
let docs_dir = temp.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let mut state_mgr = StateManager::new(&docs_dir).unwrap();
let meta = DocMetadata {
number: 1,
title: "Removed Doc".to_string(),
author: "Test Author".to_string(),
component: None,
tags: Vec::new(),
created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
updated: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
state: DocState::Removed,
supersedes: None,
superseded_by: None,
version: "1.0".to_string(),
};
let dustbin_path = docs_dir.join(".dustbin/0001-removed-doc.md");
fs::create_dir_all(dustbin_path.parent().unwrap()).unwrap();
fs::write(&dustbin_path, "test content").unwrap();
state_mgr.state_mut().upsert(
1,
DocumentRecord {
metadata: meta,
path: ".dustbin/0001-removed-doc.md".to_string(),
checksum: "abc123".to_string(),
file_size: 100,
modified: chrono::Utc::now(),
},
);
let result = list_removed_documents(&state_mgr, false, None);
assert!(result.is_ok());
}
#[test]
fn test_list_removed_documents_without_files() {
use std::fs;
let temp = TempDir::new().unwrap();
let docs_dir = temp.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let mut state_mgr = StateManager::new(&docs_dir).unwrap();
let meta = DocMetadata {
number: 1,
title: "Deleted Doc".to_string(),
author: "Test Author".to_string(),
component: None,
tags: Vec::new(),
created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
updated: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
state: DocState::Removed,
supersedes: None,
superseded_by: None,
version: "1.0".to_string(),
};
state_mgr.state_mut().upsert(
1,
DocumentRecord {
metadata: meta,
path: ".dustbin/0001-deleted-doc.md".to_string(),
checksum: "abc123".to_string(),
file_size: 100,
modified: chrono::Utc::now(),
},
);
let result = list_removed_documents(&state_mgr, false, None);
assert!(result.is_ok());
}
#[test]
fn test_list_removed_documents_verbose_with_files() {
use std::fs;
let temp = TempDir::new().unwrap();
let docs_dir = temp.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let mut state_mgr = StateManager::new(&docs_dir).unwrap();
let meta = DocMetadata {
number: 1,
title: "Removed Doc".to_string(),
author: "Test Author".to_string(),
component: None,
tags: Vec::new(),
created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
updated: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
state: DocState::Removed,
supersedes: None,
superseded_by: None,
version: "1.0".to_string(),
};
let dustbin_path = docs_dir.join(".dustbin/0001-removed-doc.md");
fs::create_dir_all(dustbin_path.parent().unwrap()).unwrap();
fs::write(&dustbin_path, "test content").unwrap();
state_mgr.state_mut().upsert(
1,
DocumentRecord {
metadata: meta,
path: ".dustbin/0001-removed-doc.md".to_string(),
checksum: "abc123".to_string(),
file_size: 100,
modified: chrono::Utc::now(),
},
);
let result = list_removed_documents(&state_mgr, true, None);
assert!(result.is_ok());
}
#[test]
fn test_list_removed_documents_mixed_overwritten_and_removed() {
use std::fs;
let temp = TempDir::new().unwrap();
let docs_dir = temp.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let mut state_mgr = StateManager::new(&docs_dir).unwrap();
for (num, title, doc_state) in
[(1, "Removed Doc", DocState::Removed), (2, "Overwritten Doc", DocState::Overwritten)]
{
let meta = DocMetadata {
number: num,
title: title.to_string(),
author: "Test Author".to_string(),
component: None,
tags: Vec::new(),
created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
updated: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
state: doc_state,
supersedes: None,
superseded_by: None,
version: "1.0".to_string(),
};
state_mgr.state_mut().upsert(
num,
DocumentRecord {
metadata: meta,
path: format!(
"{}/{:04}-{}.md",
doc_state.directory(),
num,
title.to_lowercase().replace(' ', "-")
),
checksum: "abc123".to_string(),
file_size: 100,
modified: chrono::Utc::now(),
},
);
}
let result = list_removed_documents(&state_mgr, false, None);
assert!(result.is_ok());
}
#[test]
fn test_list_removed_documents_long_title_truncation() {
use std::fs;
let temp = TempDir::new().unwrap();
let docs_dir = temp.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let mut state_mgr = StateManager::new(&docs_dir).unwrap();
let long_title = "This is a very long title that should be truncated in the output to fit the column width".to_string();
let meta = DocMetadata {
number: 1,
title: long_title,
author: "Test Author".to_string(),
component: None,
tags: Vec::new(),
created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
updated: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
state: DocState::Removed,
supersedes: None,
superseded_by: None,
version: "1.0".to_string(),
};
state_mgr.state_mut().upsert(
1,
DocumentRecord {
metadata: meta,
path: ".dustbin/0001-long-title.md".to_string(),
checksum: "abc123".to_string(),
file_size: 100,
modified: chrono::Utc::now(),
},
);
let result = list_removed_documents(&state_mgr, false, None);
assert!(result.is_ok());
}
#[test]
fn test_has_valid_prefix_valid() {
assert!(has_valid_prefix("0001-test.md"));
assert!(has_valid_prefix("9999-test.md"));
assert!(has_valid_prefix("0000-test.md"));
}
#[test]
fn test_has_valid_prefix_invalid() {
assert!(!has_valid_prefix("001-test.md")); assert!(!has_valid_prefix("test-0001.md")); assert!(!has_valid_prefix("abcd-test.md")); assert!(!has_valid_prefix("0001test.md")); assert!(!has_valid_prefix("readme.md")); }
#[test]
fn test_extract_prefix_number() {
use std::path::PathBuf;
assert_eq!(extract_prefix_number(&PathBuf::from("0001-test.md")), 1);
assert_eq!(extract_prefix_number(&PathBuf::from("0042-answer.md")), 42);
assert_eq!(extract_prefix_number(&PathBuf::from("9999-max.md")), 9999);
assert_eq!(extract_prefix_number(&PathBuf::from("invalid.md")), 0);
}
#[test]
fn test_filter_dev_file_valid() {
use std::fs;
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("0001-test.md");
fs::write(&file_path, "test content").unwrap();
let result = filter_dev_file(&file_path).unwrap();
assert!(result.is_some());
let (path, _metadata) = result.unwrap();
assert_eq!(path, file_path);
}
#[test]
fn test_filter_dev_file_invalid_prefix() {
use std::fs;
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("readme.md");
fs::write(&file_path, "test content").unwrap();
let result = filter_dev_file(&file_path).unwrap();
assert!(result.is_none());
}
#[test]
fn test_filter_dev_file_not_markdown() {
use std::fs;
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("0001-test.txt");
fs::write(&file_path, "test content").unwrap();
let result = filter_dev_file(&file_path).unwrap();
assert!(result.is_none());
}
#[test]
fn test_filter_dev_file_hidden() {
use std::fs;
let temp = TempDir::new().unwrap();
let file_path = temp.path().join(".0001-hidden.md");
fs::write(&file_path, "test content").unwrap();
let result = filter_dev_file(&file_path).unwrap();
assert!(result.is_none());
}
#[test]
fn test_collect_subdirectories_empty() {
let temp = TempDir::new().unwrap();
let result = collect_subdirectories(temp.path()).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_collect_subdirectories_with_dirs() {
use std::fs;
let temp = TempDir::new().unwrap();
fs::create_dir(temp.path().join("subdir1")).unwrap();
fs::create_dir(temp.path().join("subdir2")).unwrap();
fs::create_dir(temp.path().join(".hidden")).unwrap();
let result = collect_subdirectories(temp.path()).unwrap();
assert_eq!(result.len(), 2);
assert!(result.contains(&"subdir1".to_string()));
assert!(result.contains(&"subdir2".to_string()));
assert!(!result.contains(&".hidden".to_string()));
}
#[test]
fn test_collect_dev_docs_non_recursive() {
use std::fs;
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("0001-first.md"), "content1").unwrap();
fs::write(temp.path().join("0002-second.md"), "content2").unwrap();
fs::write(temp.path().join("readme.md"), "readme").unwrap();
let subdir = temp.path().join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(subdir.join("0003-third.md"), "content3").unwrap();
let result = collect_dev_docs(temp.path(), false).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_collect_dev_docs_recursive() {
use std::fs;
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("0001-first.md"), "content1").unwrap();
let subdir = temp.path().join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(subdir.join("0002-second.md"), "content2").unwrap();
let result = collect_dev_docs(temp.path(), true).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_collect_dev_docs_sorted_descending() {
use std::fs;
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("0001-first.md"), "content").unwrap();
fs::write(temp.path().join("0003-third.md"), "content").unwrap();
fs::write(temp.path().join("0002-second.md"), "content").unwrap();
let result = collect_dev_docs(temp.path(), false).unwrap();
let numbers: Vec<u32> =
result.iter().map(|(path, _)| extract_prefix_number(path)).collect();
assert_eq!(numbers, vec![3, 2, 1]);
}
#[test]
fn test_get_relative_path_from() {
use std::path::PathBuf;
let from = PathBuf::from("/Users/test/project");
let path = PathBuf::from("/Users/test/project/docs/file.md");
let result = get_relative_path_from(&path, &from);
assert_eq!(result, "docs/file.md");
}
#[test]
fn test_get_relative_path_from_same_dir() {
use std::path::PathBuf;
let from = PathBuf::from("/Users/test/project");
let path = PathBuf::from("/Users/test/project/file.md");
let result = get_relative_path_from(&path, &from);
assert_eq!(result, "file.md");
}
#[test]
fn test_format_mtime() {
use std::time::SystemTime;
let time = SystemTime::now();
let result = format_mtime(&time);
assert!(result.len() >= 16); assert!(result.contains('-'));
assert!(result.contains(':'));
}
}