diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cf9144e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,623 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "ssh2" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c95eb3c09e378543395a3fa9796f897861862466ee331d59140ade4ea0dcfdfc" +dependencies = [ + "bitflags", + "libc", + "libssh2-sys", + "parking_lot", +] + +[[package]] +name = "ssh2-config" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52f961ebce11d2717ec6e69447d97e62ead57a4bf1016fcf4d707d09e957530" +dependencies = [ + "bitflags", + "dirs", + "glob", + "log", + "thiserror", + "wildmatch", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "xboctploy" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "colored", + "home", + "serde", + "ssh2", + "ssh2-config", + "toml", + "walkdir", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d927a64 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "xboctploy" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.104" +clap = { version = "4.6.6", features = ["derive"] } +colored = "3.1.1" +home = "0.5.12" +serde = { version = "1.0.229", features = ["derive"] } +ssh2 = "0.9.6" +ssh2-config = "0.7.2" +toml = "1.1.4" +walkdir = "2.5.0" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..979f8e5 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; + +use clap::{ArgGroup, Parser}; + +/// Deploy hobby projects from a local PC to a VPS over SSH. +#[derive(Debug, Parser)] +#[command(name = "xboctploy", version, about)] +#[command(group = ArgGroup::new("target").required(true).args(["project", "list"]))] +pub struct Cli { + /// Project name from deploy.toml + pub project: Option, + /// Explicit path to the config file + #[arg(long, value_name = "PATH")] + pub config: Option, + /// Print planned steps without executing anything + #[arg(long)] + pub dry_run: bool, + /// List configured projects and exit + #[arg(long)] + pub list: bool, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..48e6af0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,258 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +pub const CONFIG_FILE_NAME: &str = "deploy.toml"; +pub const GLOBAL_DIR: &str = "xboctploy"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + pub projects: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Project { + pub workdir: PathBuf, + pub host: String, + pub user: Option, + #[serde(default = "default_port")] + pub port: u16, + pub key_path: Option, + #[serde(default)] + pub local: Option, + #[serde(default)] + pub sync: Vec, + #[serde(default)] + pub remote: Option, +} + +fn default_port() -> u16 { + 22 +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Commands { + pub commands: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SyncRule { + pub source: PathBuf, + pub target: PathBuf, +} + +impl Config { + pub fn validate(mut self) -> Result { + if self.projects.is_empty() { + bail!("no [projects.*] sections defined"); + } + for (name, project) in &mut self.projects { + if project.host.trim().is_empty() { + bail!("project '{name}': host must not be empty"); + } + for (phase, cmds) in [ + ("local", &mut project.local), + ("remote", &mut project.remote), + ] { + if let Some(c) = cmds + && c.commands.is_empty() + { + bail!("project '{name}': [{phase}] section present but 'commands' is empty"); + } + } + project.workdir = expand_tilde(&project.workdir) + .with_context(|| format!("project '{name}': invalid workdir"))?; + if let Some(kp) = &mut project.key_path { + *kp = expand_tilde(kp) + .with_context(|| format!("project '{name}': invalid key_path"))?; + } + for rule in &mut project.sync { + rule.source = expand_tilde(&rule.source) + .with_context(|| format!("project '{name}': invalid sync source"))?; + } + } + Ok(self) + } +} + +pub fn expand_tilde(path: &Path) -> Result { + let Some(home) = home::home_dir() else { + return Ok(path.to_path_buf()); + }; + let text = path.to_str().context("path is not valid UTF-8")?; + if text == "~" { + return Ok(home); + } + if let Some(rest) = text.strip_prefix("~/").or_else(|| text.strip_prefix("~\\")) { + return Ok(home.join(rest)); + } + Ok(path.to_path_buf()) +} + +fn pick_config(cwd: &Path, home: Option<&Path>) -> Option { + let local = cwd.join(CONFIG_FILE_NAME); + if local.is_file() { + return Some(local); + } + let global = home? + .join(".config") + .join(GLOBAL_DIR) + .join(CONFIG_FILE_NAME); + global.is_file().then_some(global) +} + +pub fn resolve(explicit: Option<&Path>) -> Result { + if let Some(p) = explicit { + return p + .is_file() + .then_some(p.to_path_buf()) + .with_context(|| format!("config not found: {}", p.display())); + } + pick_config(Path::new("."), home::home_dir().as_deref()).with_context(|| { + format!("{CONFIG_FILE_NAME} not found (looked in ./ and ~/.config/{GLOBAL_DIR}/)") + }) +} + +pub fn load(path: &Path) -> Result { + let raw = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + let cfg: Config = + toml::from_str(&raw).with_context(|| format!("invalid TOML in {}", path.display()))?; + cfg.validate() + .with_context(|| format!("invalid config {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(s: &str) -> Result { + toml::from_str::(s)?.validate() + } + + const VALID: &str = r#" +[projects.web] +workdir = "~/code/web" +host = "vps" + +[projects.web.local] +commands = ["npm run build"] + +[[projects.web.sync]] +source = "dist" +target = "/var/www/web" + +[projects.web.remote] +commands = ["pm2 restart web"] +"#; + + #[test] + fn valid_config_parses_with_defaults() { + let cfg = parse(VALID).unwrap(); + let web = cfg.projects.get("web").unwrap(); + assert_eq!(web.port, 22); + assert!(web.user.is_none()); + assert_eq!( + web.local.as_ref().unwrap().commands, + vec!["npm run build".to_string()] + ); + assert_eq!(web.sync.len(), 1); + } + + #[test] + fn broken_toml_is_rejected() { + assert!(toml::from_str::("this is [not toml").is_err()); + } + + #[test] + fn unknown_field_is_rejected() { + let s = r#" +[projects.web] +workdir = "." +host = "vps" +hostt = "typo" +"#; + assert!(toml::from_str::(s).is_err()); + } + + #[test] + fn empty_projects_table_is_rejected() { + let s = "[projects]\n"; + assert!(parse(s).is_err()); + } + + #[test] + fn empty_commands_section_is_rejected() { + let s = r#" +[projects.web] +workdir = "." +host = "vps" +[projects.web.remote] +commands = [] +"#; + assert!(parse(s).is_err()); + } + + #[test] + fn tilde_expands_to_home() { + let home = home::home_dir().unwrap(); + assert_eq!(expand_tilde(Path::new("~")).unwrap(), home); + assert_eq!( + expand_tilde(Path::new("~/.ssh/id_rsa")).unwrap(), + home.join(".ssh").join("id_rsa") + ); + assert_eq!( + expand_tilde(Path::new("/abs/path")).unwrap(), + PathBuf::from("/abs/path") + ); + } + + fn tmpdir(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("xboctploy-tests-{}-{tag}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn local_config_wins_over_global() { + let root = tmpdir("resolve"); + let cwd = root.join("proj"); + let home = root.join("home"); + fs::create_dir_all(home.join(".config").join(GLOBAL_DIR)).unwrap(); + let local = cwd.join(CONFIG_FILE_NAME); + let global = home.join(".config").join(GLOBAL_DIR).join(CONFIG_FILE_NAME); + fs::write(&global, "").unwrap(); + + assert_eq!( + pick_config(&cwd, Some(&home)).as_deref(), + Some(global.as_path()) + ); + + fs::create_dir_all(&cwd).unwrap(); + fs::write(&local, "").unwrap(); + assert_eq!( + pick_config(&cwd, Some(&home)).as_deref(), + Some(local.as_path()) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn missing_everywhere_yields_none() { + let root = tmpdir("resolve-none"); + let cwd = root.join("proj"); + fs::create_dir_all(&cwd).unwrap(); + assert_eq!(pick_config(&cwd, Some(&root.join("nope"))), None); + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8902b64 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,80 @@ +mod cli; +mod config; + +use std::process::ExitCode; + +use anyhow::{Context, Result}; +use clap::Parser; +use colored::Colorize; + +fn main() -> ExitCode { + let cli = cli::Cli::parse(); + match run(&cli) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{} {err:#}", "error:".red().bold()); + ExitCode::FAILURE + } + } +} + +fn run(cli: &cli::Cli) -> Result<()> { + let path = config::resolve(cli.config.as_deref())?; + let cfg = config::load(&path)?; + + if cli.list { + println!("config: {}", path.display()); + println!("configured projects:"); + for name in cfg.projects.keys() { + println!(" - {name}"); + } + return Ok(()); + } + + let name = cli + .project + .as_deref() + .expect("clap guarantees or --list"); + let project = cfg + .projects + .get(name) + .with_context(|| format!("project '{name}' not found in {}", path.display()))?; + + let target = match &project.user { + Some(user) => format!("{user}@{}:{}", project.host, project.port), + None => format!("{}:{}", project.host, project.port), + }; + println!( + "deploying '{name}' to {target}: {} local cmd(s), {} sync rule(s), {} remote cmd(s)", + project.local.as_ref().map_or(0, |c| c.commands.len()), + project.sync.len(), + project.remote.as_ref().map_or(0, |c| c.commands.len()), + ); + if cli.dry_run { + println!("dry-run plan:"); + for cmd in project + .local + .as_ref() + .map_or([].as_slice(), |c| c.commands.as_slice()) + { + println!(" local : {cmd}"); + } + for rule in &project.sync { + println!( + " sync : {} -> {}", + rule.source.display(), + rule.target.display() + ); + } + for cmd in project + .remote + .as_ref() + .map_or([].as_slice(), |c| c.commands.as_slice()) + { + println!(" remote: {cmd}"); + } + } else { + println!("stage 2 not implemented yet: local command execution"); + } + Ok(()) +}