diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7a8a915..6eb64aa 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,2 +1,3 @@ pub mod clean; pub mod index; +pub mod status; diff --git a/src/cli/status.rs b/src/cli/status.rs new file mode 100644 index 0000000..7807c63 --- /dev/null +++ b/src/cli/status.rs @@ -0,0 +1,147 @@ +use std::fmt; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use clap::ValueEnum; +use serde::Serialize; +use walkdir::WalkDir; + +use crate::indexer::state::{IndexState, StateError}; +use crate::output::strip_control_chars; + +/// status コマンドの出力フォーマット +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum StatusFormat { + Human, + Json, +} + +/// status コマンドのエラー型 +#[derive(Debug)] +pub enum StatusError { + State(StateError), + NotInitialized, + DirectoryNotFound(PathBuf), +} + +impl fmt::Display for StatusError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StatusError::State(e) => write!(f, "{e}"), + StatusError::NotInitialized => { + write!(f, "Index not initialized. Run `commandindex index` first.") + } + StatusError::DirectoryNotFound(p) => { + write!(f, "Directory not found: {}", p.display()) + } + } + } +} + +impl std::error::Error for StatusError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + StatusError::State(e) => Some(e), + _ => None, + } + } +} + +impl From for StatusError { + fn from(e: StateError) -> Self { + StatusError::State(e) + } +} + +/// status コマンドの出力情報 +#[derive(Debug, Serialize)] +pub struct StatusInfo { + #[serde(flatten)] + pub state: IndexState, + pub index_size_bytes: u64, +} + +/// ディレクトリサイズを再帰的に計算する +/// +/// エラーが発生したエントリはスキップし、取得可能なファイルサイズの合計を返す。 +pub fn compute_dir_size(dir: &Path) -> u64 { + WalkDir::new(dir) + .follow_links(false) + .max_depth(10) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .map(|e| e.metadata().map(|m| m.len()).unwrap_or(0)) + .sum() +} + +/// バイト数を人間が読みやすい形式にフォーマットする +pub fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * 1024 * 1024; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{bytes} B") + } +} + +/// status コマンドのメインロジック +pub fn run(path: &Path, format: StatusFormat, writer: &mut dyn Write) -> Result<(), StatusError> { + if !path.is_dir() { + return Err(StatusError::DirectoryNotFound(path.to_path_buf())); + } + + let commandindex_dir = path.join(".commandindex"); + + if !IndexState::exists(&commandindex_dir) { + return Err(StatusError::NotInitialized); + } + + let state = IndexState::load(&commandindex_dir)?; + state.check_schema_version()?; + + let index_size_bytes = compute_dir_size(&commandindex_dir); + + let info = StatusInfo { + state, + index_size_bytes, + }; + + match format { + StatusFormat::Human => { + let index_root = strip_control_chars(&info.state.index_root.display().to_string()); + writeln!(writer, "CommandIndex Status").ok(); + writeln!(writer, " Index root: {index_root}").ok(); + writeln!(writer, " Version: {}", info.state.version).ok(); + writeln!(writer, " Created: {} UTC", info.state.created_at).ok(); + writeln!( + writer, + " Last updated: {} UTC", + info.state.last_updated_at + ) + .ok(); + writeln!(writer, " Total files: {}", info.state.total_files).ok(); + writeln!(writer, " Total sections: {}", info.state.total_sections).ok(); + writeln!( + writer, + " Index size: {}", + format_size(info.index_size_bytes) + ) + .ok(); + } + StatusFormat::Json => { + let json = serde_json::to_string_pretty(&info) + .map_err(|e| StatusError::State(StateError::Json(e)))?; + writeln!(writer, "{json}").ok(); + } + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 58e2f9f..6a13d67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,14 @@ enum Commands { /// Incrementally update the index Update, /// Show index status - Status, + Status { + /// Target directory + #[arg(long, default_value = ".")] + path: PathBuf, + /// Output format (human, json) + #[arg(long, value_enum, default_value_t = commandindex::cli::status::StatusFormat::Human)] + format: commandindex::cli::status::StatusFormat, + }, /// Remove index and prepare for rebuild Clean { /// Target directory containing .commandindex/ @@ -71,9 +78,14 @@ fn main() { eprintln!("Error: `update` command is not yet implemented. Coming in Phase 2."); 1 } - Commands::Status => { - eprintln!("Error: `status` command is not yet implemented. Coming in Phase 1."); - 1 + Commands::Status { path, format } => { + match commandindex::cli::status::run(&path, format, &mut std::io::stdout()) { + Ok(()) => 0, + Err(e) => { + eprintln!("{e}"); + 1 + } + } } Commands::Clean { path } => match commandindex::cli::clean::run(&path) { Ok(commandindex::cli::clean::CleanResult::Removed) => { diff --git a/tests/cli_args.rs b/tests/cli_args.rs index 487b0f9..dacc5e3 100644 --- a/tests/cli_args.rs +++ b/tests/cli_args.rs @@ -60,14 +60,6 @@ fn update_subcommand_exits_with_not_implemented() { .stderr(predicate::str::contains("not yet implemented")); } -#[test] -fn status_subcommand_exits_with_not_implemented() { - common::cmd() - .arg("status") - .assert() - .failure() - .stderr(predicate::str::contains("not yet implemented")); -} #[test] fn search_requires_query_argument() { diff --git a/tests/cli_status.rs b/tests/cli_status.rs new file mode 100644 index 0000000..882bbb4 --- /dev/null +++ b/tests/cli_status.rs @@ -0,0 +1,197 @@ +mod common; + +use std::io::Cursor; +use std::path::PathBuf; + +use commandindex::cli::status::{StatusFormat, compute_dir_size, format_size, run}; + +// ===== format_size tests ===== + +#[test] +fn format_size_bytes() { + assert_eq!(format_size(0), "0 B"); + assert_eq!(format_size(512), "512 B"); + assert_eq!(format_size(1023), "1023 B"); +} + +#[test] +fn format_size_kilobytes() { + assert_eq!(format_size(1024), "1.0 KB"); + assert_eq!(format_size(1536), "1.5 KB"); + assert_eq!(format_size(1024 * 1023), "1023.0 KB"); +} + +#[test] +fn format_size_megabytes() { + assert_eq!(format_size(1024 * 1024), "1.0 MB"); + assert_eq!(format_size(1024 * 1024 * 500), "500.0 MB"); +} + +#[test] +fn format_size_gigabytes() { + assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB"); + assert_eq!(format_size(1024 * 1024 * 1024 * 2), "2.0 GB"); +} + +// ===== compute_dir_size tests ===== + +#[test] +fn compute_dir_size_empty_dir() { + let dir = tempfile::tempdir().expect("create temp dir"); + let size = compute_dir_size(dir.path()); + assert_eq!(size, 0); +} + +#[test] +fn compute_dir_size_with_files() { + let dir = tempfile::tempdir().expect("create temp dir"); + std::fs::write(dir.path().join("a.txt"), "hello").expect("write file"); + std::fs::write(dir.path().join("b.txt"), "world!").expect("write file"); + let size = compute_dir_size(dir.path()); + assert_eq!(size, 11); // 5 + 6 +} + +#[test] +fn compute_dir_size_nested() { + let dir = tempfile::tempdir().expect("create temp dir"); + let sub = dir.path().join("sub"); + std::fs::create_dir(&sub).expect("create subdir"); + std::fs::write(sub.join("file.txt"), "abc").expect("write file"); + let size = compute_dir_size(dir.path()); + assert_eq!(size, 3); +} + +// ===== run() error cases ===== + +#[test] +fn run_directory_not_found() { + let mut buf = Cursor::new(Vec::new()); + let path = PathBuf::from("/nonexistent/path/that/does/not/exist"); + let result = run(&path, StatusFormat::Human, &mut buf); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Directory not found")); +} + +#[test] +fn run_not_initialized() { + let dir = tempfile::tempdir().expect("create temp dir"); + let mut buf = Cursor::new(Vec::new()); + let result = run(dir.path(), StatusFormat::Human, &mut buf); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("not initialized")); +} + +// ===== run() success cases ===== + +fn setup_commandindex_dir(base: &std::path::Path) { + let ci_dir = base.join(".commandindex"); + std::fs::create_dir_all(&ci_dir).expect("create .commandindex"); + + let state = commandindex::indexer::state::IndexState::new(base.to_path_buf()); + state.save(&ci_dir).expect("save state"); +} + +#[test] +fn run_human_format() { + let dir = tempfile::tempdir().expect("create temp dir"); + setup_commandindex_dir(dir.path()); + + let mut buf = Cursor::new(Vec::new()); + run(dir.path(), StatusFormat::Human, &mut buf).expect("run should succeed"); + + let output = String::from_utf8(buf.into_inner()).expect("valid utf8"); + assert!(output.contains("CommandIndex Status")); + assert!(output.contains("Index root:")); + assert!(output.contains("Version:")); + assert!(output.contains("Created:")); + assert!(output.contains("Last updated:")); + assert!(output.contains("Total files:")); + assert!(output.contains("Total sections:")); + assert!(output.contains("Index size:")); +} + +#[test] +fn run_json_format() { + let dir = tempfile::tempdir().expect("create temp dir"); + setup_commandindex_dir(dir.path()); + + let mut buf = Cursor::new(Vec::new()); + run(dir.path(), StatusFormat::Json, &mut buf).expect("run should succeed"); + + let output = String::from_utf8(buf.into_inner()).expect("valid utf8"); + let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert!(parsed.get("version").is_some()); + assert!(parsed.get("total_files").is_some()); + assert!(parsed.get("total_sections").is_some()); + assert!(parsed.get("index_size_bytes").is_some()); + assert!(parsed.get("index_root").is_some()); + assert!(parsed.get("created_at").is_some()); + assert!(parsed.get("last_updated_at").is_some()); +} + +// ===== E2E tests via CLI binary ===== + +#[test] +fn status_cli_not_initialized() { + let dir = tempfile::tempdir().expect("create temp dir"); + common::cmd() + .args(["status", "--path", dir.path().to_str().unwrap()]) + .assert() + .failure() + .stderr(predicates::prelude::predicate::str::contains( + "not initialized", + )); +} + +#[test] +fn status_cli_human_format() { + let dir = tempfile::tempdir().expect("create temp dir"); + // First, run index to create .commandindex + common::cmd() + .args(["index", "--path", dir.path().to_str().unwrap()]) + .assert() + .success(); + + common::cmd() + .args(["status", "--path", dir.path().to_str().unwrap()]) + .assert() + .success() + .stdout(predicates::prelude::predicate::str::contains( + "CommandIndex Status", + )); +} + +#[test] +fn status_cli_json_format() { + let dir = tempfile::tempdir().expect("create temp dir"); + // First, run index to create .commandindex + common::cmd() + .args(["index", "--path", dir.path().to_str().unwrap()]) + .assert() + .success(); + + common::cmd() + .args([ + "status", + "--path", + dir.path().to_str().unwrap(), + "--format", + "json", + ]) + .assert() + .success() + .stdout(predicates::prelude::predicate::str::contains("\"version\"")); +} + +#[test] +fn status_cli_directory_not_found() { + common::cmd() + .args(["status", "--path", "/nonexistent/dir"]) + .assert() + .failure() + .stderr(predicates::prelude::predicate::str::contains( + "Directory not found", + )); +}