From 183454dfe23ecdabee0cc4c2e4d8b8cc97cb37b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20O=C5=BEana?= Date: Wed, 17 Jun 2026 10:10:42 +0200 Subject: [PATCH] Add optional .ftpsync.json config file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fill non-secret deploy options from an optional .ftpsync.json so repeated deploys don't retype --server, --exclude, --file-perms, etc. - cli.rs: server/username/password become Option (file may supply them); new --config flag. - config_file.rs (new): serde FileConfig, kebab-case, deny_unknown_fields, load(path, explicit) — missing default file is silently skipped. - config.rs: from_args -> build(args, file, &matches). Precedence is default -> file -> CLI via clap ValueSource; list flags (include/ exclude/purge) merge with CLI appended last; required fields validated after the merge with a clear hint. - main.rs: parse via ArgMatches so build() can read ValueSource. - walker.rs: exclude the config file from upload like the state file. Hard rules: no password key in the file (stays in -p / FTPSYNC_PASSWORD); CLI always overrides the file. Docs updated (README section + CLAUDE.md). Closes #1 --- CLAUDE.md | 11 ++- README.md | 54 +++++++++++++ src/cli.rs | 15 ++-- src/config.rs | 193 ++++++++++++++++++++++++++++++++++++++------- src/config_file.rs | 101 ++++++++++++++++++++++++ src/main.rs | 18 ++++- src/walker.rs | 46 ++++++++++- 7 files changed, 398 insertions(+), 40 deletions(-) create mode 100644 src/config_file.rs diff --git a/CLAUDE.md b/CLAUDE.md index b6388ef..3318e98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,10 @@ idempotent: an interrupted run re-uploads only what didn't make it. - `main.rs` — entry point: parse args → build config → `sync::run`. - `cli.rs` — clap argument definitions. -- `config.rs` — validated `Config` from args; remote-path helpers; `has_control_chars`. +- `config.rs` — validated `Config` via `Config::build(args, file, &matches)`; + remote-path helpers; `has_control_chars`. +- `config_file.rs` — optional `.ftpsync.json` (`FileConfig`, serde, kebab-case, + `deny_unknown_fields`); `load(path, explicit)`. - `walker.rs` — local file discovery + filtering. - `ignore.rs` — `.ftpignore` parsing (gitignore semantics via the `ignore` crate). - `hasher.rs` — streaming SHA-256 (`sha256:`). @@ -47,6 +50,12 @@ idempotent: an interrupted run re-uploads only what didn't make it. deploys but does not prevent concurrency (exists-check + upload aren't atomic). - State `BTreeMap` keeps output deterministic. The on-disk format is shared with a parallel Bun implementation; keep `version` / shape compatible. +- Config precedence is **default → `.ftpsync.json` → CLI**: `Config::build` reads + clap's `ValueSource` (via `cli_set`) so an explicit flag overrides the file but + a default does not; list flags (`include`/`exclude`/`purge`) merge (CLI appended + last). The password is never read from the file (no `password` key) — it stays + in `-p` / `FTPSYNC_PASSWORD`. The config file is excluded from upload like the + state file (`rel_posix == cfg.config_file`). ## Build, test, lint diff --git a/README.md b/README.md index 78d8f5a..0c40e00 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ Always **preview first** with `--dry-run`: ftpsync -s ftp.example.com -u deploy -r /www --dry-run -v ``` +For repeated deploys, commit the non-secret options to a +[`.ftpsync.json`](#configuration-file) so a run is just `FTPSYNC_PASSWORD=… ftpsync`. + ## Usage ``` @@ -132,6 +135,7 @@ ftpsync [OPTIONS] --server --username --password | `-l, --local-dir ` | `.` | Local source directory | | `-r, --server-dir ` | `/` | Remote target directory | | `--state-file ` | `.ftpsync-state.json` | State file name on the server | +| `--config ` | `.ftpsync.json` | [Config file](#configuration-file) to pre-fill options | ### Filters @@ -181,6 +185,56 @@ ftpsync -s ftp.example.com -u deploy -r /www --insecure-tls ftpsync -s ftp.example.com -u deploy -r /www -j 8 ``` +## Configuration file + +For repeated deploys you can commit a project's non-secret settings to a +`.ftpsync.json` instead of retyping flags every run. It is **optional**: if the +default `.ftpsync.json` is absent it is silently ignored, and you can point +elsewhere with `--config `. The file is looked up in the current working +directory (no upward tree search), and it is never uploaded to the server. + +Keys map 1:1 to the CLI flags (kebab-case), all optional: + +```json +{ + "server": "ftp.example.com", + "port": 21, + "username": "deploy", + "secure": "explicit", + "passive": true, + "timeout": 30, + "local-dir": ".", + "server-dir": "/www", + "state-file": ".ftpsync-state.json", + "include": ["dist/**"], + "exclude": ["vendor/**", "uploads/**"], + "ignore-file": ".ftpignore", + "no-delete": false, + "purge": ["cache/views"], + "file-perms": "0644", + "dir-perms": "0755", + "concurrency": 8 +} +``` + +With that committed, a deploy is just: + +```bash +FTPSYNC_PASSWORD='s3cret' ftpsync +``` + +Rules: + +- **No password in the file.** There is no `password` key; it must come from + `-p` / `FTPSYNC_PASSWORD`, so it never lands in git. (Same for the per-run + toggles `--dry-run` / `--verbose` / `--quiet`.) +- **Precedence is default → file → CLI.** A CLI flag always overrides the file; + the file overrides the built-in default. +- **List flags merge.** `include` / `exclude` / `purge` from the file and the + CLI are combined (the CLI's entries appended last), not replaced. +- **Unknown keys are errors**, so a typo like `"serverr"` fails loudly instead + of being silently ignored. + ## `.ftpignore` Gitignore syntax, read from `--local-dir` by default: diff --git a/src/cli.rs b/src/cli.rs index a165dab..2034f2b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,8 @@ use clap::{Parser, ValueEnum}; /// Secure connection mode for the FTP control/data channels. -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, serde::Deserialize)] +#[serde(rename_all = "lowercase")] pub enum SecureMode { /// Plain FTP, no TLS. None, @@ -21,18 +22,22 @@ pub enum SecureMode { about = "Hash-based deploy over FTPS without SSH" )] pub struct Args { - // --- Required --- + /// Path to the config file (default: .ftpsync.json in the current directory). + #[arg(long, value_name = "PATH")] + pub config: Option, + + // --- Required (here or in the config file) --- /// FTP server hostname. #[arg(short = 's', long)] - pub server: String, + pub server: Option, /// FTP username. #[arg(short = 'u', long)] - pub username: String, + pub username: Option, /// FTP password (prefer the FTPSYNC_PASSWORD env var for CI). #[arg(short = 'p', long, env = "FTPSYNC_PASSWORD", hide_env_values = true)] - pub password: String, + pub password: Option, // --- Connection --- /// FTP port. diff --git a/src/config.rs b/src/config.rs index 4ce1152..7f926d8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,15 @@ //! Validated configuration derived from CLI arguments. use crate::cli::{Args, SecureMode}; +use crate::config_file::FileConfig; use crate::error::{FtpSyncError, Result}; +use clap::parser::ValueSource; +use clap::ArgMatches; use std::path::PathBuf; +/// Default config file name, looked up in the current directory. +pub const DEFAULT_CONFIG_FILE: &str = ".ftpsync.json"; + /// Verbosity level derived from -v / -q. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verbosity { @@ -43,18 +49,53 @@ pub struct Config { pub concurrency: usize, pub dry_run: bool, pub verbosity: Verbosity, + + /// Config file path, excluded from upload the same way `state_file` is. + pub config_file: String, +} + +/// True if the user explicitly supplied the arg (on the CLI or via env), +/// as opposed to clap filling in a default or leaving it unset. This is how +/// we let a CLI flag override the config file while a default does not. +fn cli_set(matches: &ArgMatches, id: &str) -> bool { + matches!( + matches.value_source(id), + Some(ValueSource::CommandLine) | Some(ValueSource::EnvVariable) + ) } impl Config { - /// Build a validated configuration from parsed CLI args. - pub fn from_args(args: Args) -> Result { - if args.server.trim().is_empty() { - return Err(FtpSyncError::Config("server must not be empty".into())); + /// Build a validated configuration from CLI args, the (already loaded) + /// config file, and clap's match metadata. + /// + /// Precedence is default -> file -> CLI: a scalar takes the file value + /// unless the flag was set explicitly; list flags (include/exclude/purge) + /// merge the file's entries with the CLI's, CLI appended last. + pub fn build(args: Args, file: FileConfig, matches: &ArgMatches) -> Result { + // Scalar with a clap default: file wins over the default, CLI wins over both. + macro_rules! pick { + ($id:literal, $cli:expr, $file:expr) => { + if cli_set(matches, $id) { + $cli + } else { + $file.unwrap_or($cli) + } + }; } - if args.username.trim().is_empty() { - return Err(FtpSyncError::Config("username must not be empty".into())); - } - if args.concurrency == 0 { + + // server/username have no clap default, so an unset CLI value is `None` + // and falls back to the file. The password is intentionally absent from + // the file (it must not land in git); it comes from `-p` / the env only. + let server = require(args.server.or(file.server), "server", "--server")?; + let username = require(args.username.or(file.username), "username", "--username")?; + let password = require( + args.password, + "password", + "--password / the FTPSYNC_PASSWORD env var", + )?; + + let concurrency = pick!("concurrency", args.concurrency, file.concurrency); + if concurrency == 0 { return Err(FtpSyncError::Config("concurrency must be >= 1".into())); } if args.verbose && args.quiet { @@ -63,7 +104,7 @@ impl Config { )); } - let local_dir = PathBuf::from(&args.local_dir); + let local_dir = PathBuf::from(pick!("local_dir", args.local_dir, file.local_dir)); if !local_dir.is_dir() { return Err(FtpSyncError::Config(format!( "local dir does not exist or is not a directory: {}", @@ -72,10 +113,18 @@ impl Config { } // --no-auto-init overrides --auto-init (which defaults to true). - let auto_init = !args.no_auto_init; + let auto_init = !pick!("no_auto_init", args.no_auto_init, file.no_auto_init); + + // These have no clap default either, so `.or` falls back to the file. + let file_perms_raw = args.file_perms.or(file.file_perms); + let dir_perms_raw = args.dir_perms.or(file.dir_perms); + let file_perms = parse_octal(file_perms_raw.as_deref(), "--file-perms")?; + let dir_perms = parse_octal(dir_perms_raw.as_deref(), "--dir-perms")?; - let file_perms = parse_octal(args.file_perms.as_deref(), "--file-perms")?; - let dir_perms = parse_octal(args.dir_perms.as_deref(), "--dir-perms")?; + // List flags merge: file entries first, CLI entries appended last. + let include = merge_vec(file.include, args.include); + let exclude = merge_vec(file.exclude, args.exclude); + let purge = merge_vec(file.purge, args.purge); let verbosity = if args.quiet { Verbosity::Quiet @@ -85,30 +134,39 @@ impl Config { Verbosity::Normal }; + let config_file = args + .config + .unwrap_or_else(|| DEFAULT_CONFIG_FILE.to_string()); + Ok(Config { - server: args.server, - username: args.username, - password: args.password, - port: args.port, - secure: args.secure, - insecure_tls: args.insecure_tls, - passive: args.passive, - timeout: args.timeout, + server, + username, + password, + port: pick!("port", args.port, file.port), + secure: pick!("secure", args.secure, file.secure), + insecure_tls: pick!("insecure_tls", args.insecure_tls, file.insecure_tls), + passive: pick!("passive", args.passive, file.passive), + timeout: pick!("timeout", args.timeout, file.timeout), local_dir, - server_dir: normalize_remote_dir(&args.server_dir), - state_file: args.state_file, - include: args.include, - exclude: args.exclude, - ignore_file: args.ignore_file, - no_ignore_file: args.no_ignore_file, + server_dir: normalize_remote_dir(&pick!( + "server_dir", + args.server_dir, + file.server_dir + )), + state_file: pick!("state_file", args.state_file, file.state_file), + include, + exclude, + ignore_file: pick!("ignore_file", args.ignore_file, file.ignore_file), + no_ignore_file: pick!("no_ignore_file", args.no_ignore_file, file.no_ignore_file), auto_init, - no_delete: args.no_delete, - purge: args.purge, + no_delete: pick!("no_delete", args.no_delete, file.no_delete), + purge, file_perms, dir_perms, - concurrency: args.concurrency, + concurrency, dry_run: args.dry_run, verbosity, + config_file, }) } @@ -118,6 +176,24 @@ impl Config { } } +/// Unwrap a required credential that may come from the CLI or the config file, +/// rejecting an absent or blank value with a hint at where to set it. +fn require(value: Option, name: &str, where_: &str) -> Result { + match value { + Some(v) if !v.trim().is_empty() => Ok(v), + _ => Err(FtpSyncError::Config(format!( + "{name} is required: set {where_} or put \"{name}\" in {DEFAULT_CONFIG_FILE}" + ))), + } +} + +/// Merge a list flag: the config file's entries first, then the CLI's appended +/// last (so a CLI value wins on conflict and is applied later). +fn merge_vec(mut file: Vec, cli: Vec) -> Vec { + file.extend(cli); + file +} + /// Parse an optional octal permission string like "0644" or "755" into its value. fn parse_octal(value: Option<&str>, flag: &str) -> Result> { let Some(raw) = value else { @@ -163,6 +239,65 @@ pub fn join_remote(dir: &str, rel: &str) -> String { #[cfg(test)] mod tests { use super::*; + use clap::{CommandFactory, FromArgMatches}; + + /// Build a Config from a CLI argv (without the program name) and a file config. + fn build_from(argv: &[&str], file: FileConfig) -> Result { + let mut full = vec!["ftpsync"]; + full.extend_from_slice(argv); + let matches = Args::command().get_matches_from(full); + let args = Args::from_arg_matches(&matches).unwrap(); + Config::build(args, file, &matches) + } + + #[test] + fn cli_overrides_file_scalar() { + let file = FileConfig { + server: Some("file-host".into()), + username: Some("file-user".into()), + server_dir: Some("/www".into()), + ..Default::default() + }; + // server-dir given on the CLI must win over the file's "/www". + let cfg = build_from(&["-p", "pw", "--server-dir", "/public"], file).unwrap(); + assert_eq!(cfg.server, "file-host"); // from file (no CLI value) + assert_eq!(cfg.server_dir, "public"); // CLI wins, normalized + } + + #[test] + fn file_fills_when_cli_is_default() { + let file = FileConfig { + server: Some("file-host".into()), + username: Some("file-user".into()), + server_dir: Some("/www".into()), + concurrency: Some(8), + secure: Some(SecureMode::Implicit), + ..Default::default() + }; + let cfg = build_from(&["-p", "pw"], file).unwrap(); + assert_eq!(cfg.server_dir, "www"); + assert_eq!(cfg.concurrency, 8); + assert_eq!(cfg.secure, SecureMode::Implicit); + } + + #[test] + fn vec_flags_merge_with_cli_last() { + let file = FileConfig { + server: Some("h".into()), + username: Some("u".into()), + exclude: vec!["vendor/**".into()], + ..Default::default() + }; + let cfg = build_from(&["-p", "pw", "--exclude", "uploads/**"], file).unwrap(); + assert_eq!(cfg.exclude, vec!["vendor/**", "uploads/**"]); // file first, CLI last + } + + #[test] + fn missing_required_field_errors() { + // No server anywhere -> a clear error after the merge. + let err = build_from(&["-u", "u", "-p", "pw"], FileConfig::default()).unwrap_err(); + assert!(matches!(err, FtpSyncError::Config(_))); + } #[test] fn normalize_root() { diff --git a/src/config_file.rs b/src/config_file.rs new file mode 100644 index 0000000..20d553a --- /dev/null +++ b/src/config_file.rs @@ -0,0 +1,101 @@ +//! Optional `.ftpsync.json` config file: pre-fills non-secret deploy options. +//! +//! Keys map 1:1 to the CLI flags (kebab-case). Two hard rules: there is no +//! `password` key (it stays in `FTPSYNC_PASSWORD` / `-p` so it never lands in +//! git), and a CLI flag always overrides the file value (see `config::build`). +//! `deny_unknown_fields` turns a mistyped key into an error rather than a +//! silently ignored setting. + +use crate::cli::SecureMode; +use crate::error::{FtpSyncError, Result}; +use serde::Deserialize; +use std::path::Path; + +/// Deserialized `.ftpsync.json`. Every field is optional; scalars use `Option` +/// (absent = "not set, fall back to default/CLI") and list flags use `Vec` +/// (absent = empty, merged with the CLI values). +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct FileConfig { + pub server: Option, + pub port: Option, + pub username: Option, + pub secure: Option, + pub insecure_tls: Option, + pub passive: Option, + pub timeout: Option, + pub local_dir: Option, + pub server_dir: Option, + pub state_file: Option, + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, + pub ignore_file: Option, + pub no_ignore_file: Option, + pub no_auto_init: Option, + pub no_delete: Option, + #[serde(default)] + pub purge: Vec, + pub file_perms: Option, + pub dir_perms: Option, + pub concurrency: Option, +} + +/// Load a config file. When `explicit` is false (the path is the default, +/// `--config` was not passed), a missing file is not an error — an empty +/// `FileConfig` is returned. Parse errors and other I/O errors always surface. +pub fn load(path: &Path, explicit: bool) -> Result { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|e| FtpSyncError::Config(format!("failed to parse {}: {e}", path.display()))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound && !explicit => { + Ok(FileConfig::default()) + } + Err(e) => Err(FtpSyncError::Config(format!( + "failed to read {}: {e}", + path.display() + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| FtpSyncError::Config(format!("parse: {e}"))) + } + + #[test] + fn parses_known_fields() { + let fc = parse( + r#"{ "server": "ftp.example.com", "secure": "explicit", + "exclude": ["vendor/**"], "file-perms": "0644" }"#, + ) + .unwrap(); + assert_eq!(fc.server.as_deref(), Some("ftp.example.com")); + assert_eq!(fc.secure, Some(SecureMode::Explicit)); + assert_eq!(fc.exclude, vec!["vendor/**".to_string()]); + assert_eq!(fc.file_perms.as_deref(), Some("0644")); + } + + #[test] + fn rejects_unknown_key() { + // A mistyped key must be an error, not silently ignored. + assert!(parse(r#"{ "serverr": "typo" }"#).is_err()); + } + + #[test] + fn rejects_password_key() { + // There is deliberately no `password` field, so it is an unknown key. + assert!(parse(r#"{ "password": "secret" }"#).is_err()); + } + + #[test] + fn empty_object_is_all_none() { + let fc = parse("{}").unwrap(); + assert!(fc.server.is_none()); + assert!(fc.exclude.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 56c465e..4e4c397 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,13 @@ //! ftpsync — hash-based deploy over FTPS without SSH. use anyhow::Result; -use clap::Parser; +use clap::{CommandFactory, FromArgMatches}; +use std::path::Path; mod cli; mod client; mod config; +mod config_file; mod error; mod hasher; mod ignore; @@ -16,8 +18,18 @@ mod walker; #[tokio::main] async fn main() -> Result<()> { - let args = cli::Args::parse(); - let cfg = config::Config::from_args(args)?; + // Parse via ArgMatches so `config::build` can tell an explicit flag from a + // default (clap's ValueSource) when merging the config file. + let matches = cli::Args::command().get_matches(); + let args = cli::Args::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()); + + let config_path = args + .config + .clone() + .unwrap_or_else(|| config::DEFAULT_CONFIG_FILE.to_string()); + let file = config_file::load(Path::new(&config_path), args.config.is_some())?; + + let cfg = config::Config::build(args, file, &matches)?; log::set_verbosity(cfg.verbosity); sync::run(cfg).await?; Ok(()) diff --git a/src/walker.rs b/src/walker.rs index 55d7247..daaeba9 100644 --- a/src/walker.rs +++ b/src/walker.rs @@ -91,8 +91,8 @@ pub fn discover(cfg: &Config) -> Result> { ))); } - // Never sync the state file itself if it lives in the tree. - if rel_posix == cfg.state_file { + // Never sync the state file or the config file if they live in the tree. + if rel_posix == cfg.state_file || rel_posix == cfg.config_file { continue; } @@ -138,4 +138,46 @@ mod tests { assert!(gs.is_match("foo.log")); assert!(!gs.is_match("index.php")); } + + /// Build a default Config rooted at `dir` (credentials are dummies). + fn cfg_for(dir: &Path) -> Config { + use crate::cli::Args; + use clap::{CommandFactory, FromArgMatches}; + let argv = vec![ + "ftpsync", + "-s", + "h", + "-u", + "u", + "-p", + "pw", + "-l", + dir.to_str().unwrap(), + ]; + let matches = Args::command().get_matches_from(argv); + let args = Args::from_arg_matches(&matches).unwrap(); + Config::build(args, crate::config_file::FileConfig::default(), &matches).unwrap() + } + + #[test] + fn skips_state_and_config_files() { + use std::fs; + let dir = std::env::temp_dir().join(format!("ftpsync-walk-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("index.html"), b"x").unwrap(); + fs::write(dir.join(".ftpsync-state.json"), b"{}").unwrap(); + fs::write(dir.join(".ftpsync.json"), b"{}").unwrap(); + + let cfg = cfg_for(&dir); + let names: Vec = discover(&cfg) + .unwrap() + .into_iter() + .map(|f| f.rel_path) + .collect(); + let _ = fs::remove_dir_all(&dir); + + // Neither the state file nor the config file is uploaded. + assert_eq!(names, vec!["index.html".to_string()]); + } }