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
86 changes: 39 additions & 47 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ pub struct Cli {
#[derive(Args)]
pub struct CipherArgs {
/// Text to process
#[arg(short, long)]
#[arg(short, long, conflicts_with = "file")]
pub text: Option<String>,

/// Input file path
#[arg(short = 'f', long)]
#[arg(short = 'f', long, conflicts_with = "text")]
pub file: Option<String>,

/// Shift value (any integer; safe mode: -25 to 25, default: 3)
Expand Down Expand Up @@ -57,11 +57,11 @@ pub enum Commands {
/// Show all possible decryptions (brute force)
BruteForce {
/// Text to decrypt
#[arg(short, long)]
#[arg(short, long, conflicts_with = "file")]
text: Option<String>,

/// Input file path
#[arg(short = 'f', long)]
#[arg(short = 'f', long, conflicts_with = "text")]
file: Option<String>,
},
}
Expand Down Expand Up @@ -136,52 +136,47 @@ pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
/// # Errors
///
/// Returns an error if:
/// - Both text and file are provided simultaneously
/// - File reading fails
/// - Stdin reading fails
fn get_input_text(
text: Option<String>,
file: Option<String>,
) -> Result<String, Box<dyn std::error::Error>> {
match (text, file) {
(Some(t), None) => {
if t.len() > MAX_INPUT_SIZE {
return Err(format!(
"Input text exceeds maximum size of {} bytes",
MAX_INPUT_SIZE
)
.into());
}
Ok(t)
if let Some(t) = text {
if t.len() > MAX_INPUT_SIZE {
return Err(format!(
"Input text exceeds maximum size of {} bytes",
MAX_INPUT_SIZE
)
.into());
}
(None, Some(f)) => {
let metadata =
fs::metadata(&f).map_err(|e| format!("Failed to read file '{}': {}", f, e))?;
if metadata.len() > MAX_INPUT_SIZE as u64 {
return Err(format!(
"Input file '{}' exceeds maximum size of {} bytes",
f, MAX_INPUT_SIZE
)
.into());
}
fs::read_to_string(&f).map_err(|e| format!("Failed to read file '{}': {}", f, e).into())
}
(Some(_), Some(_)) => Err("Cannot specify both text and file".into()),
(None, None) => {
print!("Enter text: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if input.len() > MAX_INPUT_SIZE {
return Err(format!(
"Input text exceeds maximum size of {} bytes",
MAX_INPUT_SIZE
)
.into());
}
Ok(trim_trailing_newline(&input).to_string())
return Ok(t);
}

if let Some(f) = file {
let metadata = fs::metadata(&f).map_err(|e| format!("Failed to read file '{}': {}", f, e))?;
if metadata.len() > MAX_INPUT_SIZE as u64 {
return Err(format!(
"Input file '{}' exceeds maximum size of {} bytes",
f, MAX_INPUT_SIZE
)
.into());
}
return fs::read_to_string(&f).map_err(|e| format!("Failed to read file '{}': {}", f, e).into());
}

print!("Enter text: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if input.len() > MAX_INPUT_SIZE {
return Err(format!(
"Input text exceeds maximum size of {} bytes",
MAX_INPUT_SIZE
)
.into());
}
Ok(input.trim().to_string())
}

fn trim_trailing_newline(input: &str) -> &str {
Expand Down Expand Up @@ -391,13 +386,10 @@ mod tests {
}

#[test]
fn test_get_input_text_both_provided() {
fn test_get_input_text_prefers_text_when_both_provided() {
let result = get_input_text(Some("Hello".to_string()), Some("file.txt".to_string()));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Cannot specify both text and file"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Hello");
}

#[test]
Expand Down
41 changes: 37 additions & 4 deletions tests/cli_output_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! | テスト観点 | 分類 | 期待値 |
//! |----------------------------------|----------|---------------------------------------|
//! | 存在しないファイル読込 | 異常系 | エラーメッセージにファイルパス含む |
//! | text+file同時指定 | 異常系 | "Cannot specify both" エラー |
//! | text+file同時指定 | 異常系 | clap が即エラーを返す |
//! | テキスト引数のみ指定 | 正常系 | テキストがそのまま返る |
//! | ファイルから正常読込 | 正常系 | ファイル内容が返る |
//! | 空ファイル読込 | 境界値 | 空文字列返却 |
Expand Down Expand Up @@ -180,10 +180,43 @@ fn test_cli_both_text_and_file_error() {

let stderr = String::from_utf8_lossy(&output.stderr);

// Then: Error message mentions the conflict
// Then: clap validates conflict before application logic
assert!(!output.status.success(), "Command should fail on clap validation");
assert!(
stderr.contains("Cannot specify both"),
"Error should mention 'Cannot specify both', got: {}",
stderr.contains("cannot be used with") || stderr.contains("conflicts with"),
"Error should mention clap conflict, got: {}",
stderr
);
}

#[test]
fn test_cli_brute_force_both_text_and_file_error() {
// Given: Both text and file arguments provided for brute-force
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "content").unwrap();
let file_path = temp_file.path().to_string_lossy().to_string();

// When: CLI receives both arguments
let output = std::process::Command::new("cargo")
.args([
"run",
"--",
"brute-force",
"--text",
"Khoor",
"--file",
&file_path,
])
.output()
.expect("Failed to execute CLI");

let stderr = String::from_utf8_lossy(&output.stderr);

// Then: clap validates conflict before application logic
assert!(!output.status.success(), "Command should fail on clap validation");
assert!(
stderr.contains("cannot be used with") || stderr.contains("conflicts with"),
"Error should mention clap conflict, got: {}",
stderr
);
}
Expand Down
Loading