use crate::app::FilePriority;
use crate::fs_atomic::write_bytes_atomically;
use crate::integrations::control::{write_control_request, ControlPriorityTarget, ControlRequest};
use crate::integrations::status::status_file_path;
use clap::{Parser, Subcommand, ValueEnum};
use sha1::{Digest, Sha1};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, SystemTime};
#[derive(Parser, Debug)]
#[command(
author,
version,
about = "A BitTorrent client with local CLI automation and optional shared cluster mode.",
long_about = None
)]
pub struct Cli {
#[arg(long, global = true, help = "Return structured JSON output")]
pub json: bool,
#[arg(help = "Add a torrent file path or magnet link without using a subcommand")]
pub input: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
#[command(about = "Add one or more torrent paths or magnet links")]
Add {
#[arg(
value_name = "INPUT",
num_args = 1..,
help = "Torrent file path(s) or magnet link(s)"
)]
inputs: Vec<String>,
},
#[command(about = "Request graceful shutdown of the running client or shared leader")]
StopClient,
#[command(about = "Show the event journal")]
Journal,
#[command(about = "Persist the shared root used for launcher and protocol-handler starts")]
SetSharedConfig {
#[arg(
value_name = "PATH",
help = "Shared mount root or explicit superseedr-config path"
)]
path: PathBuf,
},
#[command(about = "Clear the persisted shared root launcher setting")]
ClearSharedConfig,
#[command(about = "Show the effective shared root selection and its source")]
ShowSharedConfig,
#[command(about = "Persist an explicit host identity for shared mode (optional)")]
SetHostId {
#[arg(
value_name = "HOST_ID",
help = "Stable host identity to use in shared mode"
)]
host_id: String,
},
#[command(about = "Clear the persisted shared host identity")]
ClearHostId,
#[command(about = "Show the effective host identity selection and its source")]
ShowHostId,
#[command(about = "Convert the current standalone config into layered shared config")]
ToShared {
#[arg(
value_name = "PATH",
help = "Shared mount root or explicit superseedr-config path"
)]
path: PathBuf,
},
#[command(about = "Convert the active shared config back into standalone local config")]
ToStandalone,
#[command(about = "List configured torrents")]
Torrents,
#[command(about = "Show one torrent by info hash, or resolve it from a unique file path")]
Info {
#[arg(
value_name = "INFO_HASH_HEX_OR_PATH",
help = "Torrent info hash or unique file path"
)]
target: String,
},
#[command(about = "Read status once, stream status updates, or stop status streaming")]
Status {
#[arg(long, help = "Continuously print updated status snapshots")]
follow: bool,
#[arg(long, help = "Stop runtime status streaming in standalone mode")]
stop: bool,
#[arg(
long,
value_name = "SECONDS",
help = "Set the runtime status dump interval"
)]
interval: Option<u64>,
},
#[command(about = "Pause one or more torrents by info hash or unique file path")]
Pause {
#[arg(
value_name = "INFO_HASH_HEX_OR_PATH",
help = "One or more torrent targets"
)]
targets: Vec<String>,
},
#[command(about = "Resume one or more torrents by info hash or unique file path")]
Resume {
#[arg(
value_name = "INFO_HASH_HEX_OR_PATH",
help = "One or more torrent targets"
)]
targets: Vec<String>,
},
#[command(about = "Remove one or more torrents without deleting payload data")]
Remove {
#[arg(
value_name = "INFO_HASH_HEX_OR_PATH",
help = "One or more torrent targets"
)]
targets: Vec<String>,
},
#[command(about = "Remove one or more torrents and delete payload data when safe")]
Purge {
#[arg(
value_name = "INFO_HASH_HEX_OR_PATH",
help = "One or more torrent targets"
)]
targets: Vec<String>,
},
#[command(about = "List files for a torrent by info hash or unique file path")]
Files {
#[arg(
value_name = "INFO_HASH_HEX_OR_PATH",
help = "Torrent info hash or unique file path"
)]
target: String,
},
#[command(about = "Set file priority for a torrent by file index or relative path")]
Priority {
#[arg(
value_name = "INFO_HASH_HEX_OR_PATH",
help = "Torrent info hash or unique file path"
)]
target: String,
#[arg(
long,
conflicts_with = "file_path",
help = "Target a file by zero-based file index"
)]
file_index: Option<usize>,
#[arg(
long,
conflicts_with = "file_index",
help = "Target a file by relative file path"
)]
file_path: Option<String>,
#[arg(help = "Priority to apply")]
priority: CliPriority,
},
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CliPriority {
Normal,
High,
Skip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusCommandMode {
Snapshot,
Follow { interval_secs: u64 },
SetInterval { interval_secs: u64 },
Stop,
}
impl From<CliPriority> for FilePriority {
fn from(value: CliPriority) -> Self {
match value {
CliPriority::Normal => FilePriority::Normal,
CliPriority::High => FilePriority::High,
CliPriority::Skip => FilePriority::Skip,
}
}
}
pub fn write_input_command(input_str: &str, watch_path: &Path) -> io::Result<PathBuf> {
fs::create_dir_all(watch_path)?;
if input_str.starts_with("magnet:") {
let hash_bytes = Sha1::digest(input_str.as_bytes());
let file_hash_hex = hex::encode(hash_bytes);
let final_filename = format!("{}.magnet", file_hash_hex);
let final_path = watch_path.join(final_filename);
tracing::info!(
"Attempting to write magnet link atomically to final path: {:?}",
final_path
);
match write_bytes_atomically(&final_path, input_str.as_bytes()) {
Ok(_) => Ok(final_path),
Err(e) => {
tracing::error!("Failed to write magnet file atomically: {}", e);
Err(e)
}
}
} else {
let torrent_path = PathBuf::from(input_str);
match fs::canonicalize(&torrent_path) {
Ok(absolute_path) => {
let absolute_path_cow = absolute_path.to_string_lossy();
write_path_command_payload(
absolute_path_cow.as_ref(),
absolute_path_cow.as_ref(),
watch_path,
)
}
Err(e) => {
if !input_str.starts_with("magnet:") {
tracing::warn!(
"Input '{}' is not a valid torrent file path: {}",
input_str,
e
);
}
Err(io::Error::new(io::ErrorKind::InvalidInput, e))
}
}
}
}
pub fn write_path_command_payload(
path_payload: &str,
hash_key: &str,
watch_path: &Path,
) -> io::Result<PathBuf> {
fs::create_dir_all(watch_path)?;
let hash_bytes = Sha1::digest(hash_key.as_bytes());
let file_hash_hex = hex::encode(hash_bytes);
let final_filename = format!("{}.path", file_hash_hex);
let final_dest_path = watch_path.join(final_filename);
tracing::info!(
"Attempting to write torrent path atomically to final path: {:?}",
final_dest_path
);
match write_bytes_atomically(&final_dest_path, path_payload.as_bytes()) {
Ok(_) => Ok(final_dest_path),
Err(e) => {
tracing::error!("Failed to write path file atomically: {}", e);
Err(e)
}
}
}
pub fn write_stop_command(watch_path: &Path) -> io::Result<PathBuf> {
fs::create_dir_all(watch_path)?;
let file_path = watch_path.join("shutdown.cmd");
fs::write(&file_path, "STOP")?;
Ok(file_path)
}
#[cfg(test)]
pub fn command_to_control_requests(
command: &Commands,
) -> Result<Option<Vec<ControlRequest>>, String> {
command_to_control_requests_with_resolver(command, |target, _| Ok(target.to_string()))
}
pub fn command_to_control_requests_with_resolver<F>(
command: &Commands,
mut resolve_target: F,
) -> Result<Option<Vec<ControlRequest>>, String>
where
F: FnMut(&str, &str) -> Result<String, String>,
{
match command {
Commands::Status { .. } => Ok(Some(vec![status_control_request(command)?])),
Commands::Pause { targets } => Ok(Some(
require_cli_targets(targets, "pause")?
.into_iter()
.map(|target| resolve_target(&target, "pause"))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(|info_hash_hex| ControlRequest::Pause { info_hash_hex })
.collect(),
)),
Commands::Resume { targets } => Ok(Some(
require_cli_targets(targets, "resume")?
.into_iter()
.map(|target| resolve_target(&target, "resume"))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(|info_hash_hex| ControlRequest::Resume { info_hash_hex })
.collect(),
)),
Commands::Remove { targets } => Ok(Some(
require_cli_targets(targets, "remove")?
.into_iter()
.map(|target| resolve_target(&target, "remove"))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(|info_hash_hex| ControlRequest::Delete {
info_hash_hex,
delete_files: false,
})
.collect(),
)),
Commands::Priority {
target,
file_index,
file_path,
priority,
} => {
let info_hash_hex = resolve_target(target, "priority")?;
let target = if let Some(file_index) = file_index {
ControlPriorityTarget::FileIndex(*file_index)
} else if let Some(file_path) = file_path {
ControlPriorityTarget::FilePath(file_path.clone())
} else {
return Err("Priority requires either --file-index or --file-path".to_string());
};
Ok(Some(vec![ControlRequest::SetFilePriority {
info_hash_hex,
target,
priority: (*priority).into(),
}]))
}
Commands::Add { .. }
| Commands::StopClient
| Commands::Journal
| Commands::SetSharedConfig { .. }
| Commands::ClearSharedConfig
| Commands::ShowSharedConfig
| Commands::SetHostId { .. }
| Commands::ClearHostId
| Commands::ShowHostId
| Commands::ToShared { .. }
| Commands::ToStandalone
| Commands::Torrents
| Commands::Info { .. }
| Commands::Purge { .. }
| Commands::Files { .. } => Ok(None),
}
}
pub fn status_command_mode(command: &Commands) -> Result<StatusCommandMode, String> {
let Commands::Status {
follow,
stop,
interval,
} = command
else {
return Err("Expected status command".to_string());
};
if *follow && *stop {
return Err("Choose either --follow or --stop, not both".to_string());
}
if *stop && interval.is_some() {
return Err("Do not use --interval together with --stop".to_string());
}
Ok(if *stop {
StatusCommandMode::Stop
} else if *follow {
StatusCommandMode::Follow {
interval_secs: interval.unwrap_or(5),
}
} else if let Some(interval_secs) = interval {
StatusCommandMode::SetInterval {
interval_secs: *interval_secs,
}
} else {
StatusCommandMode::Snapshot
})
}
pub fn status_control_request(command: &Commands) -> Result<ControlRequest, String> {
Ok(match status_command_mode(command)? {
StatusCommandMode::Snapshot => ControlRequest::StatusNow,
StatusCommandMode::Follow { interval_secs }
| StatusCommandMode::SetInterval { interval_secs } => {
ControlRequest::StatusFollowStart { interval_secs }
}
StatusCommandMode::Stop => ControlRequest::StatusFollowStop,
})
}
#[cfg(test)]
pub fn command_to_control_request(command: &Commands) -> Result<Option<ControlRequest>, String> {
match command_to_control_requests(command)? {
Some(mut requests) => {
let request = requests
.drain(..)
.next()
.ok_or_else(|| "No control requests were produced".to_string())?;
Ok(Some(request))
}
None => Ok(None),
}
}
pub fn require_cli_targets(values: &[String], command_name: &str) -> Result<Vec<String>, String> {
let targets = values
.iter()
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if targets.is_empty() {
return Err(format!(
"Missing target for `superseedr {}`. Use either INFO_HASH_HEX or a file path.",
command_name
));
}
Ok(targets)
}
pub fn expand_add_inputs(inputs: &[String]) -> Vec<String> {
let mut expanded = Vec::new();
for input in inputs {
if input.starts_with("magnet:") || Path::new(input).exists() {
expanded.push(input.clone());
continue;
}
let mut split_values = input
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if split_values.is_empty() {
continue;
}
if split_values.len() == 1 {
expanded.push(split_values.remove(0));
} else {
expanded.extend(split_values);
}
}
expanded
}
pub fn write_control_command(request: &ControlRequest, watch_path: &Path) -> io::Result<PathBuf> {
write_control_request(request, watch_path)
}
pub fn wait_for_status_json_after(
previous_modified_at: Option<SystemTime>,
timeout: Duration,
) -> io::Result<String> {
let status_path = status_file_path()?;
let deadline = std::time::Instant::now() + timeout;
loop {
if let Ok(metadata) = fs::metadata(&status_path) {
let modified_at = metadata.modified().ok();
let is_new_enough = match (previous_modified_at, modified_at) {
(Some(previous), Some(current)) => current > previous,
(None, Some(_)) => true,
(_, None) => false,
};
if is_new_enough || previous_modified_at.is_none() {
return fs::read_to_string(&status_path);
}
}
if std::time::Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"Timed out waiting for a fresh status dump",
));
}
thread::sleep(Duration::from_millis(200));
}
}
pub fn status_file_modified_at() -> io::Result<Option<SystemTime>> {
let status_path = status_file_path()?;
match fs::metadata(status_path) {
Ok(metadata) => Ok(metadata.modified().ok()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
use std::fs::{self, File};
use std::io::Write;
fn setup_temp_dir() -> (PathBuf, impl Drop) {
let dir = std::env::temp_dir().join(format!("superseedr_test_{}", rand::random::<u32>()));
fs::create_dir_all(&dir).unwrap();
let dir_clone = dir.clone();
struct Cleaner(PathBuf);
impl Drop for Cleaner {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
(dir, Cleaner(dir_clone))
}
#[test]
fn test_process_input_magnet() {
let (watch_dir, _cleaner) = setup_temp_dir();
let magnet_link = "magnet:?xt=urn:btih:5b63529350414441534441534441534441534441";
write_input_command(magnet_link, &watch_dir).expect("write magnet command");
let hash_bytes = Sha1::digest(magnet_link.as_bytes());
let expected_name = format!("{}.magnet", hex::encode(hash_bytes));
let expected_path = watch_dir.join(expected_name);
assert!(expected_path.exists(), "Magnet file should exist");
let content = fs::read_to_string(expected_path).unwrap();
assert_eq!(
content, magnet_link,
"File content should be the magnet link"
);
}
#[test]
fn test_process_input_torrent_path() {
let (watch_dir, _cleaner) = setup_temp_dir();
let torrent_source_name = "test_linux.torrent";
let torrent_source_path = watch_dir.join(torrent_source_name);
{
let mut f = File::create(&torrent_source_path).unwrap();
f.write_all(b"dummy torrent content").unwrap();
}
let abs_source_path = fs::canonicalize(&torrent_source_path).unwrap();
write_input_command(abs_source_path.to_str().unwrap(), &watch_dir)
.expect("write path command");
let hash_bytes = Sha1::digest(abs_source_path.to_string_lossy().as_bytes());
let expected_name = format!("{}.path", hex::encode(hash_bytes));
let expected_path_file = watch_dir.join(expected_name);
assert!(expected_path_file.exists(), ".path file should be created");
let content = fs::read_to_string(expected_path_file).unwrap();
assert_eq!(
content,
abs_source_path.to_string_lossy(),
".path file should contain the absolute path"
);
}
#[test]
fn test_process_invalid_path() {
let (watch_dir, _cleaner) = setup_temp_dir();
let bad_path = "/path/to/nonexistent/file.torrent";
assert!(write_input_command(bad_path, &watch_dir).is_err());
let count = fs::read_dir(&watch_dir).unwrap().count();
assert_eq!(count, 0, "No files should be created for invalid input");
}
#[test]
fn status_command_maps_to_runtime_requests() {
let follow = Commands::Status {
follow: true,
stop: false,
interval: None,
};
let request = status_control_request(&follow).expect("map status command");
assert_eq!(
request,
ControlRequest::StatusFollowStart { interval_secs: 5 }
);
}
#[test]
fn status_interval_maps_to_runtime_request_without_follow() {
let command = Commands::Status {
follow: false,
stop: false,
interval: Some(30),
};
let request = status_control_request(&command).expect("map status interval");
assert_eq!(
request,
ControlRequest::StatusFollowStart { interval_secs: 30 }
);
assert_eq!(
status_command_mode(&command),
Ok(StatusCommandMode::SetInterval { interval_secs: 30 })
);
}
#[test]
fn priority_requires_one_target() {
let command = Commands::Priority {
target: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
file_index: None,
file_path: None,
priority: CliPriority::High,
};
assert!(command_to_control_request(&command).is_err());
}
#[test]
fn journal_command_is_not_mapped_to_control_request() {
assert!(matches!(
command_to_control_request(&Commands::Journal),
Ok(None)
));
}
#[test]
fn torrents_command_is_not_mapped_to_control_request() {
assert!(matches!(
command_to_control_request(&Commands::Torrents),
Ok(None)
));
}
#[test]
fn info_command_is_not_mapped_to_control_request() {
assert!(matches!(
command_to_control_request(&Commands::Info {
target: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()
}),
Ok(None)
));
}
#[test]
fn remove_without_target_returns_helpful_error() {
let error = command_to_control_request(&Commands::Remove {
targets: Vec::new(),
})
.expect_err("missing target should fail");
assert!(error.contains("Missing target"));
assert!(error.contains("INFO_HASH_HEX"));
assert!(error.contains("file path"));
}
#[test]
fn shared_config_commands_are_not_mapped_to_control_request() {
assert!(matches!(
command_to_control_request(&Commands::SetSharedConfig {
path: PathBuf::from("C:/shared-root")
}),
Ok(None)
));
assert!(matches!(
command_to_control_request(&Commands::ClearSharedConfig),
Ok(None)
));
assert!(matches!(
command_to_control_request(&Commands::ShowSharedConfig),
Ok(None)
));
}
#[test]
fn remove_command_supports_multiple_hashes() {
let requests = command_to_control_requests(&Commands::Remove {
targets: vec![
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
],
})
.expect("map delete commands")
.expect("requests");
assert_eq!(requests.len(), 2);
}
#[test]
fn purge_requires_at_least_one_target() {
let error = require_cli_targets(&[], "purge").expect_err("missing target should fail");
assert!(error.contains("Missing target"));
}
#[test]
fn add_command_expands_comma_separated_non_magnet_inputs() {
let expanded = expand_add_inputs(&["alpha.torrent,beta.torrent".to_string()]);
assert_eq!(
expanded,
vec!["alpha.torrent".to_string(), "beta.torrent".to_string()]
);
}
#[test]
fn cli_priority_command_parses_without_panicking() {
Cli::command().debug_assert();
let parsed = Cli::try_parse_from([
"superseedr",
"priority",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"--file-index",
"0",
"skip",
])
.expect("priority command should parse");
match parsed.command.expect("subcommand") {
Commands::Priority {
target,
file_index,
file_path,
priority,
} => {
assert_eq!(target, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
assert_eq!(file_index, Some(0));
assert_eq!(file_path, None);
assert_eq!(priority, CliPriority::Skip);
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn resolved_pause_command_supports_file_lookup() {
let requests = command_to_control_requests_with_resolver(
&Commands::Pause {
targets: vec!["C:/seedbox/downloads/sample.bin".to_string()],
},
|target, command_name| {
assert_eq!(target, "C:/seedbox/downloads/sample.bin");
assert_eq!(command_name, "pause");
Ok("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string())
},
)
.expect("map pause commands")
.expect("requests");
assert_eq!(
requests,
vec![ControlRequest::Pause {
info_hash_hex: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()
}]
);
}
#[test]
fn cli_set_shared_config_command_parses_without_panicking() {
Cli::command().debug_assert();
let parsed = Cli::try_parse_from([
"superseedr",
"set-shared-config",
"C:\\shared-root\\superseedr-config",
])
.expect("set-shared-config command should parse");
match parsed.command.expect("subcommand") {
Commands::SetSharedConfig { path } => {
assert_eq!(path, PathBuf::from("C:\\shared-root\\superseedr-config"));
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn cli_set_host_id_command_parses_without_panicking() {
Cli::command().debug_assert();
let parsed = Cli::try_parse_from(["superseedr", "set-host-id", "office-node"])
.expect("set-host-id command should parse");
match parsed.command.expect("subcommand") {
Commands::SetHostId { host_id } => {
assert_eq!(host_id, "office-node");
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn cli_to_shared_command_parses_without_panicking() {
Cli::command().debug_assert();
let parsed = Cli::try_parse_from(["superseedr", "to-shared", "C:\\shared-root"])
.expect("to-shared command should parse");
match parsed.command.expect("subcommand") {
Commands::ToShared { path } => {
assert_eq!(path, PathBuf::from("C:\\shared-root"));
}
other => panic!("unexpected command: {other:?}"),
}
}
}