Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod clean;
pub mod index;
pub mod status;
147 changes: 147 additions & 0 deletions src/cli/status.rs
Original file line number Diff line number Diff line change
@@ -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<StateError> 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(())
}
20 changes: 16 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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) => {
Expand Down
8 changes: 0 additions & 8 deletions tests/cli_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
197 changes: 197 additions & 0 deletions tests/cli_status.rs
Original file line number Diff line number Diff line change
@@ -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",
));
}
Loading