diff --git a/Cargo.lock b/Cargo.lock index 66bd114..e52b1aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -332,6 +338,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -479,6 +494,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "doc-comment" version = "0.3.4" @@ -606,12 +632,31 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -745,12 +790,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "ignore" version = "0.4.25" @@ -866,6 +1014,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" @@ -878,12 +1032,44 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "nom" version = "7.1.3" @@ -924,6 +1110,23 @@ dependencies = [ "toml", ] +[[package]] +name = "notforge" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "miette", + "notcore", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "toml", + "ureq", +] + [[package]] name = "notgraph" version = "0.1.0" @@ -1148,6 +1351,12 @@ dependencies = [ "base64ct", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pkcs1" version = "0.7.5" @@ -1186,6 +1395,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1333,6 +1551,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rpassword" version = "7.4.0" @@ -1397,6 +1629,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1556,6 +1823,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "smallvec" version = "1.15.1" @@ -1578,6 +1851,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1601,6 +1880,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1660,6 +1950,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tls_codec" version = "0.4.2" @@ -1734,6 +2034,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1750,6 +2056,48 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1886,6 +2234,24 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "7.0.3" @@ -2160,6 +2526,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -2186,6 +2558,29 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "yubikey" version = "0.8.0" @@ -2239,6 +2634,27 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" @@ -2259,6 +2675,39 @@ dependencies = [ "syn", ] +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index ef8db75..fb2e762 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,14 @@ members = [ "crates/notnet", "crates/notstrap", "crates/notgraph", + "crates/notforge", "tests/integration", ] resolver = "2" [workspace.dependencies] anyhow = "1" +base64 = "0.22" clap = { version = "4", features = ["derive"] } clap_complete = "4" chrono = { version = "0.4", default-features = false, features = ["clock"] } @@ -31,6 +33,8 @@ serde_json = "1" strsim = "0.11" thiserror = "2" toml = "0.8" +miette = { version = "7", features = ["derive"] } +ureq = { version = "2", features = ["json"] } which = "7" rpassword = "7" tempfile = "3" @@ -40,6 +44,7 @@ notfiles = { path = "crates/notfiles" } notsecrets = { path = "crates/notsecrets" } nothooks = { path = "crates/nothooks" } notnet = { path = "crates/notnet" } +notforge = { path = "crates/notforge" } [profile.release] opt-level = 3 diff --git a/crates/notcore/src/config.rs b/crates/notcore/src/config.rs index 101db30..4d9dbb2 100644 --- a/crates/notcore/src/config.rs +++ b/crates/notcore/src/config.rs @@ -1,3 +1,4 @@ +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; @@ -85,6 +86,22 @@ impl std::fmt::Display for Method { } } +/// Load and deserialize a TOML config file, mapping I/O and parse errors +/// into the caller's crate-specific error type. +pub fn load_toml_file( + path: &Path, + read_error: ReadError, + parse_error: ParseError, +) -> Result +where + T: DeserializeOwned, + ReadError: FnOnce(&Path, std::io::Error) -> E, + ParseError: FnOnce(&Path, toml::de::Error) -> E, +{ + let content = std::fs::read_to_string(path).map_err(|err| read_error(path, err))?; + toml::from_str(&content).map_err(|err| parse_error(path, err)) +} + impl Config { pub fn load(dotfiles_dir: &Path) -> Result { let config_path = dotfiles_dir.join("notfiles.toml"); @@ -204,6 +221,10 @@ ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfil mod tests { use super::*; + fn temp_config_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("notcore-{name}-{}.toml", std::process::id())) + } + #[test] fn test_default_config() { let config = Config::default(); @@ -323,4 +344,40 @@ target = "~/bin" assert!(ssh_ignores.contains(&".git")); assert!(ssh_ignores.contains(&"known_hosts")); } + + #[test] + fn load_toml_file_deserializes_config() { + let path = temp_config_path("load-toml-file-ok"); + std::fs::write(&path, "[defaults]\ntarget = \"~/dotfiles\"\n") + .expect("test should write temporary config"); + + let config: Config = load_toml_file( + &path, + |path, err| format!("read {}: {err}", path.display()), + |path, err| format!("parse {}: {err}", path.display()), + ) + .expect("temporary config should parse"); + + assert_eq!(config.defaults.target, "~/dotfiles"); + + std::fs::remove_file(&path).expect("test should remove temporary config"); + } + + #[test] + fn load_toml_file_maps_parse_error() { + let path = temp_config_path("load-toml-file-parse-error"); + std::fs::write(&path, "[defaults\ntarget = \"~/dotfiles\"\n") + .expect("test should write invalid temporary config"); + + let result: Result = load_toml_file( + &path, + |path, err| format!("read {}: {err}", path.display()), + |path, err| format!("parse {}: {err}", path.display()), + ); + + let err = result.expect_err("invalid TOML should return parse error"); + assert!(err.contains("parse ")); + + std::fs::remove_file(&path).expect("test should remove temporary config"); + } } diff --git a/crates/notforge/Cargo.toml b/crates/notforge/Cargo.toml new file mode 100644 index 0000000..e1c1979 --- /dev/null +++ b/crates/notforge/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "notforge" +version = "0.1.0" +edition = "2024" +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Forge lifecycle and repository provisioning for notfiles" + +[lib] +name = "notforge" +path = "src/lib.rs" + +[[bin]] +name = "notforge" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +clap = { workspace = true } +miette = { workspace = true } +notcore = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } +ureq = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/notforge/src/config.rs b/crates/notforge/src/config.rs new file mode 100644 index 0000000..435a41e --- /dev/null +++ b/crates/notforge/src/config.rs @@ -0,0 +1,176 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use std::path::Path; + +use crate::error::NotforgeError; + +/// Top-level forge configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForgeConfig { + /// Gitea backend configuration. + pub gitea: GiteaConfig, + /// Repositories managed by this forge configuration. + #[serde(default)] + pub repositories: Vec, +} + +/// Gitea backend configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GiteaConfig { + /// Base HTTP URL for the Gitea API. + pub base_url: String, + /// Default owner namespace for repositories. + pub owner: String, + /// Hostname used in generated SSH clone URLs. + pub ssh_host: String, + /// SSH port used in generated SSH clone URLs. + pub ssh_port: u16, + /// Backend mode used to verify or start Gitea. + pub mode: GiteaMode, + /// Authentication configuration for Gitea API calls. + #[serde(default)] + pub auth: ForgeAuthConfig, +} + +/// Supported Gitea backend modes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum GiteaMode { + /// Run Gitea directly as a local process. + LocalProcess(LocalProcessConfig), + /// Run Gitea via a local container runtime. + LocalContainer(LocalContainerConfig), + /// Verify or start Gitea as a systemd service on a VM. + VmSystemd(VmSystemdConfig), + /// Use an already-running Gitea instance. + Existing, +} + +impl<'de> Deserialize<'de> for GiteaMode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum RawMode { + Name(String), + Tagged { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + config: Option, + }, + } + + let raw = RawMode::deserialize(deserializer)?; + match raw { + RawMode::Name(name) if name == "existing" => Ok(Self::Existing), + RawMode::Name(name) => Err(serde::de::Error::custom(format!( + "unknown gitea mode '{name}'" + ))), + RawMode::Tagged { kind, config } => match kind.as_str() { + "existing" => Ok(Self::Existing), + "local-process" => Ok(Self::LocalProcess( + config + .ok_or_else(|| serde::de::Error::custom("local-process requires config"))? + .try_into() + .map_err(serde::de::Error::custom)?, + )), + "local-container" => Ok(Self::LocalContainer( + config + .ok_or_else(|| serde::de::Error::custom("local-container requires config"))? + .try_into() + .map_err(serde::de::Error::custom)?, + )), + "vm-systemd" => Ok(Self::VmSystemd( + config + .ok_or_else(|| serde::de::Error::custom("vm-systemd requires config"))? + .try_into() + .map_err(serde::de::Error::custom)?, + )), + other => Err(serde::de::Error::custom(format!( + "unknown gitea mode '{other}'" + ))), + }, + } + } +} + +/// Local process runner configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalProcessConfig { + /// Path or command name for the Gitea binary. + pub binary: String, + /// Working directory for the Gitea process. + pub work_dir: String, + /// Gitea app.ini path. + pub config_path: String, +} + +/// Local container runner configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalContainerConfig { + /// Container runtime executable, such as docker or podman. + pub runtime: String, + /// Container name to verify or start. + pub container_name: String, + /// Container image to run when missing. + pub image: String, + /// Host data directory mounted into the container. + pub data_dir: String, +} + +/// Remote VM systemd runner configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VmSystemdConfig { + /// SSH user for the VM. + pub ssh_user: String, + /// SSH host for the VM. + pub ssh_host: String, + /// systemd service name for Gitea. + pub service_name: String, +} + +/// Authentication configuration for Gitea API calls. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ForgeAuthConfig { + /// Token secret reference, preferred when available. + pub token: Option, + /// Username secret reference for basic auth fallback. + pub username: Option, + /// Password secret reference for basic auth fallback. + pub password: Option, +} + +/// Secret reference used by notforge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "source", rename_all = "lowercase")] +pub enum ForgeSecretRef { + /// Resolve from an environment variable. + Env { key: String }, + /// Resolve from a 1Password URI. + Op { uri: String }, +} + +/// Repository specification managed by a forge config. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepoSpec { + /// Repository owner namespace. + pub owner: String, + /// Repository name. + pub name: String, + /// Whether the repository should be private. + pub private: bool, + /// Optional repository description. + pub description: Option, + /// Local git remote name. + pub remote_name: String, +} + +/// Load a forge configuration from TOML. +pub fn load_config(path: &Path) -> Result { + notcore::config::load_toml_file( + path, + |path, err| NotforgeError::Config(format!("reading {}: {err}", path.display())), + |path, err| NotforgeError::Config(format!("parsing {}: {err}", path.display())), + ) +} diff --git a/crates/notforge/src/error.rs b/crates/notforge/src/error.rs new file mode 100644 index 0000000..748d8da --- /dev/null +++ b/crates/notforge/src/error.rs @@ -0,0 +1,36 @@ +use miette::Diagnostic; +use thiserror::Error; + +/// Error type for forge lifecycle, API, git, and secret-resolution failures. +#[derive(Debug, Diagnostic, Error)] +pub enum NotforgeError { + /// Configuration could not be loaded or parsed. + #[error("configuration error: {0}")] + #[diagnostic(code(notforge::config))] + Config(String), + + /// A required secret could not be resolved. + #[error("secret resolution error: {0}")] + #[diagnostic(code(notforge::secret))] + Secret(String), + + /// Forge API communication failed. + #[error("HTTP error: {0}")] + #[diagnostic(code(notforge::http))] + Http(String), + + /// A lifecycle command failed. + #[error("command error: {0}")] + #[diagnostic(code(notforge::command))] + Command(String), + + /// A git operation failed. + #[error("git error: {0}")] + #[diagnostic(code(notforge::git))] + Git(String), + + /// Repository lookup or provisioning failed. + #[error("repository error: {0}")] + #[diagnostic(code(notforge::repository))] + Repository(String), +} diff --git a/crates/notforge/src/gitea.rs b/crates/notforge/src/gitea.rs new file mode 100644 index 0000000..f641688 --- /dev/null +++ b/crates/notforge/src/gitea.rs @@ -0,0 +1,302 @@ +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::config::RepoSpec; +use crate::error::NotforgeError; +use crate::ports::{ForgeApi, ForgeAuth, ForgeVersion, RemoteRepository}; + +/// HTTP method used by the Gitea transport boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HttpMethod { + /// HTTP GET. + Get, + /// HTTP POST. + Post, +} + +impl std::fmt::Display for HttpMethod { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Get => formatter.write_str("GET"), + Self::Post => formatter.write_str("POST"), + } + } +} + +/// Transport-level request sent by the Gitea adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GiteaHttpRequest { + /// HTTP method. + pub method: HttpMethod, + /// Absolute URL to call. + pub url: String, + /// Optional Authorization header value. + pub authorization: Option, + /// Optional JSON body. + pub body: Option, +} + +/// Transport-level response returned to the Gitea adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GiteaHttpResponse { + /// HTTP status code. + pub status: u16, + /// Parsed JSON response body, or `null` for an empty body. + pub body: Value, +} + +/// HTTP transport boundary for Gitea API requests. +pub trait GiteaTransport { + /// Send a transport-level request. + fn send(&self, request: GiteaHttpRequest) -> Result; +} + +/// Gitea API adapter that implements [`ForgeApi`]. +#[derive(Debug, Clone)] +pub struct GiteaHttpApi { + base_url: String, + transport: T, +} + +impl GiteaHttpApi { + /// Create a Gitea API adapter backed by the default blocking HTTP transport. + pub fn new(base_url: impl Into) -> Self { + Self::with_transport(base_url, UreqGiteaTransport::default()) + } +} + +impl GiteaHttpApi +where + T: GiteaTransport, +{ + /// Create a Gitea API adapter with an injected transport. + pub fn with_transport(base_url: impl Into, transport: T) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + transport, + } + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + fn get(&self, path: &str) -> Result { + self.transport.send(GiteaHttpRequest { + method: HttpMethod::Get, + url: self.url(path), + authorization: None, + body: None, + }) + } + + fn post( + &self, + path: &str, + auth: &ForgeAuth, + body: Value, + ) -> Result { + self.transport.send(GiteaHttpRequest { + method: HttpMethod::Post, + url: self.url(path), + authorization: Some(authorization_header(auth)), + body: Some(body), + }) + } +} + +impl ForgeApi for GiteaHttpApi +where + T: GiteaTransport, +{ + fn version(&self) -> Result { + let response = self.get("/api/v1/version")?; + require_status(response.status, &[200], "get Gitea version")?; + let payload: GiteaVersionResponse = parse_body(response.body, "Gitea version response")?; + Ok(ForgeVersion { + version: payload.version, + }) + } + + fn repo(&self, owner: &str, name: &str) -> Result, NotforgeError> { + let response = self.get(&format!("/api/v1/repos/{owner}/{name}"))?; + if response.status == 404 { + return Ok(None); + } + + require_status(response.status, &[200], "get Gitea repository")?; + let payload: GiteaRepositoryResponse = + parse_body(response.body, "Gitea repository response")?; + Ok(Some(payload.into_remote_repository())) + } + + fn create_repo( + &self, + spec: &RepoSpec, + auth: &ForgeAuth, + ) -> Result { + let response = self.post("/api/v1/user/repos", auth, create_repo_body(spec))?; + require_status(response.status, &[200, 201], "create Gitea repository")?; + let payload: GiteaRepositoryResponse = + parse_body(response.body, "Gitea repository response")?; + Ok(payload.into_remote_repository()) + } +} + +/// Blocking HTTP transport implemented with `ureq`. +#[derive(Debug, Clone)] +pub struct UreqGiteaTransport { + agent: ureq::Agent, +} + +impl Default for UreqGiteaTransport { + fn default() -> Self { + Self { + agent: ureq::Agent::new(), + } + } +} + +impl UreqGiteaTransport { + /// Create a transport with a custom `ureq` agent. + pub fn new(agent: ureq::Agent) -> Self { + Self { agent } + } +} + +impl GiteaTransport for UreqGiteaTransport { + fn send(&self, request: GiteaHttpRequest) -> Result { + let method = request.method; + let url = request.url.clone(); + let mut call = match method { + HttpMethod::Get => self.agent.get(&url), + HttpMethod::Post => self.agent.post(&url), + } + .set("Accept", "application/json"); + + if let Some(authorization) = request.authorization { + call = call.set("Authorization", &authorization); + } + + let result = match method { + HttpMethod::Get => call.call(), + HttpMethod::Post => call + .set("Content-Type", "application/json") + .send_json(request.body.unwrap_or_else(|| json!({}))), + }; + + match result { + Ok(response) => response_from_ureq(response), + Err(ureq::Error::Status(status, response)) => response_from_status(status, response), + Err(err) => Err(NotforgeError::Http(format!( + "{method} {url} failed before response: {err}" + ))), + } + } +} + +#[derive(Debug, Deserialize)] +struct GiteaVersionResponse { + version: String, +} + +#[derive(Debug, Deserialize)] +struct GiteaRepositoryResponse { + owner: GiteaOwnerResponse, + name: String, + clone_url: String, + ssh_url: String, +} + +impl GiteaRepositoryResponse { + fn into_remote_repository(self) -> RemoteRepository { + RemoteRepository { + owner: self.owner.login, + name: self.name, + clone_url: self.clone_url, + ssh_url: self.ssh_url, + } + } +} + +#[derive(Debug, Deserialize)] +struct GiteaOwnerResponse { + login: String, +} + +#[derive(Debug, Serialize)] +struct CreateRepoRequest<'a> { + name: &'a str, + private: bool, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<&'a str>, +} + +fn create_repo_body(spec: &RepoSpec) -> Value { + serde_json::to_value(CreateRepoRequest { + name: &spec.name, + private: spec.private, + description: spec.description.as_deref(), + }) + .expect("CreateRepoRequest serialization should not fail") +} + +fn authorization_header(auth: &ForgeAuth) -> String { + match auth { + ForgeAuth::Token { token } => format!("token {token}"), + ForgeAuth::Basic { username, password } => { + format!( + "Basic {}", + STANDARD.encode(format!("{username}:{password}")) + ) + } + } +} + +fn require_status(status: u16, expected: &[u16], action: &str) -> Result<(), NotforgeError> { + if expected.contains(&status) { + Ok(()) + } else { + Err(NotforgeError::Http(format!( + "{action} returned unexpected status {status}" + ))) + } +} + +fn parse_body(body: Value, context: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_value(body) + .map_err(|err| NotforgeError::Http(format!("invalid {context}: {err}"))) +} + +fn response_from_status( + status: u16, + response: ureq::Response, +) -> Result { + response_from_parts(status, response) +} + +fn response_from_ureq(response: ureq::Response) -> Result { + response_from_parts(response.status(), response) +} + +fn response_from_parts( + status: u16, + response: ureq::Response, +) -> Result { + let body = response + .into_string() + .map_err(|err| NotforgeError::Http(format!("reading HTTP response body: {err}")))?; + let body = if body.trim().is_empty() { + Value::Null + } else { + serde_json::from_str(&body) + .map_err(|err| NotforgeError::Http(format!("parsing HTTP response JSON: {err}")))? + }; + + Ok(GiteaHttpResponse { status, body }) +} diff --git a/crates/notforge/src/lib.rs b/crates/notforge/src/lib.rs new file mode 100644 index 0000000..680b8c1 --- /dev/null +++ b/crates/notforge/src/lib.rs @@ -0,0 +1,19 @@ +//! Forge lifecycle and repository provisioning for notfiles. + +pub mod config; +pub mod error; +pub mod gitea; +pub mod ports; + +/// Current `notforge` crate version. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exposes_package_version() { + assert_eq!(VERSION, env!("CARGO_PKG_VERSION")); + } +} diff --git a/crates/notforge/src/main.rs b/crates/notforge/src/main.rs new file mode 100644 index 0000000..04a6805 --- /dev/null +++ b/crates/notforge/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("{}", notforge::VERSION); +} diff --git a/crates/notforge/src/ports.rs b/crates/notforge/src/ports.rs new file mode 100644 index 0000000..bb2b67d --- /dev/null +++ b/crates/notforge/src/ports.rs @@ -0,0 +1,126 @@ +use std::path::PathBuf; + +use crate::config::{ForgeConfig, ForgeSecretRef, RepoSpec}; +use crate::error::NotforgeError; + +/// Authentication material resolved for forge API requests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ForgeAuth { + /// Token-based Gitea API authentication. + Token { token: String }, + /// Basic authentication fallback for bootstrap flows. + Basic { username: String, password: String }, +} + +/// Remote repository metadata returned by a forge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteRepository { + /// Repository owner namespace. + pub owner: String, + /// Repository name. + pub name: String, + /// HTTP clone URL. + pub clone_url: String, + /// SSH clone URL. + pub ssh_url: String, +} + +/// Forge version metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForgeVersion { + /// Server version string. + pub version: String, +} + +/// Local git repository path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalRepository { + /// Filesystem path to the local repository. + pub path: PathBuf, +} + +/// Result of ensuring a forge backend is available. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LifecycleStatus { + /// The forge was already reachable. + AlreadyAvailable, + /// The forge was started by this operation. + Started, + /// The forge was verified without needing a start operation. + Verified, +} + +/// Result of setting a local git remote. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteStatus { + /// The remote was added. + Added, + /// The remote existed and was updated. + Updated, + /// The remote already matched the desired URL. + Unchanged, +} + +/// Result of pushing a local branch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PushStatus { + /// The branch was pushed. + Pushed, + /// The branch already matched the remote. + AlreadyUpToDate, +} + +/// Port for forge API operations. +pub trait ForgeApi { + /// Return server version metadata. + fn version(&self) -> Result; + + /// Look up a repository by owner and name. + fn repo(&self, owner: &str, name: &str) -> Result, NotforgeError>; + + /// Create a repository from a spec and resolved auth. + fn create_repo( + &self, + spec: &RepoSpec, + auth: &ForgeAuth, + ) -> Result; +} + +/// Port for local or remote forge lifecycle operations. +pub trait ForgeLifecycle { + /// Ensure the configured forge backend is available. + fn ensure_available(&self, config: &ForgeConfig) -> Result; +} + +/// Port for git remote and push operations. +pub trait GitRemoteManager { + /// Return the URL for a local remote if it exists. + fn remote_url( + &self, + repo: &LocalRepository, + remote_name: &str, + ) -> Result, NotforgeError>; + + /// Add or update a local git remote. + fn set_remote( + &self, + repo: &LocalRepository, + remote_name: &str, + url: &str, + ) -> Result; + + /// Push a branch to a local remote. + fn push( + &self, + repo: &LocalRepository, + remote_name: &str, + branch: &str, + set_upstream: bool, + ) -> Result; +} + +/// Port for resolving forge secrets. +pub trait SecretResolverPort { + /// Resolve a secret reference into an in-memory secret value. + fn resolve(&self, secret: &ForgeSecretRef) -> Result; +} diff --git a/crates/notforge/tests/api.rs b/crates/notforge/tests/api.rs new file mode 100644 index 0000000..c620413 --- /dev/null +++ b/crates/notforge/tests/api.rs @@ -0,0 +1,222 @@ +use std::path::PathBuf; + +use notforge::config::{ + ForgeAuthConfig, ForgeConfig, ForgeSecretRef, GiteaMode, RepoSpec, VmSystemdConfig, load_config, +}; +use notforge::error::NotforgeError; +use notforge::ports::{ + ForgeApi, ForgeAuth, ForgeLifecycle, ForgeVersion, GitRemoteManager, LifecycleStatus, + LocalRepository, PushStatus, RemoteRepository, RemoteStatus, SecretResolverPort, +}; + +fn assert_diagnostic() {} + +#[test] +fn notforge_error_implements_miette_diagnostic() { + assert_diagnostic::(); +} + +#[test] +fn config_parses_vm_systemd_gitea_mode() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = dir.path().join("notforge.toml"); + std::fs::write( + &config_path, + r#" + [gitea] + base_url = "http://gitea.local:3000" + owner = "joe" + ssh_host = "gitea.local" + ssh_port = 2222 + + [gitea.mode] + type = "vm-systemd" + + [gitea.mode.config] + ssh_user = "dev" + ssh_host = "gitea.local" + service_name = "gitea" + + [gitea.auth.token] + source = "env" + key = "GITEA_TOKEN" + + [[repositories]] + owner = "joe" + name = "notfiles-config" + private = true + description = "Personal config payload" + remote_name = "gitea" + "#, + ) + .unwrap(); + + let config = load_config(&config_path).unwrap(); + + assert_eq!(config.gitea.base_url, "http://gitea.local:3000"); + assert_eq!(config.gitea.owner, "joe"); + assert_eq!(config.gitea.ssh_port, 2222); + assert_eq!( + config.gitea.auth.token, + Some(ForgeSecretRef::Env { + key: "GITEA_TOKEN".to_string() + }) + ); + assert_eq!( + config.gitea.mode, + GiteaMode::VmSystemd(VmSystemdConfig { + ssh_user: "dev".to_string(), + ssh_host: "gitea.local".to_string(), + service_name: "gitea".to_string(), + }) + ); + assert_eq!( + config.repositories, + vec![RepoSpec { + owner: "joe".to_string(), + name: "notfiles-config".to_string(), + private: true, + description: Some("Personal config payload".to_string()), + remote_name: "gitea".to_string(), + }] + ); +} + +#[test] +fn config_defaults_repositories_to_empty() { + let config: ForgeConfig = toml::from_str( + r#" + [gitea] + base_url = "http://localhost:3000" + owner = "joe" + ssh_host = "localhost" + ssh_port = 2222 + mode = "existing" + "#, + ) + .unwrap(); + + assert!(config.repositories.is_empty()); + assert_eq!(config.gitea.auth, ForgeAuthConfig::default()); +} + +#[test] +fn ports_are_object_safe_for_adapters() { + struct FakeApi; + impl ForgeApi for FakeApi { + fn version(&self) -> Result { + Ok(ForgeVersion { + version: "1.0.0".to_string(), + }) + } + + fn repo( + &self, + _owner: &str, + _name: &str, + ) -> Result, NotforgeError> { + Ok(None) + } + + fn create_repo( + &self, + spec: &RepoSpec, + _auth: &ForgeAuth, + ) -> Result { + Ok(RemoteRepository { + owner: spec.owner.clone(), + name: spec.name.clone(), + clone_url: "http://example/repo.git".to_string(), + ssh_url: "ssh://git@example/joe/repo.git".to_string(), + }) + } + } + + struct FakeLifecycle; + impl ForgeLifecycle for FakeLifecycle { + fn ensure_available( + &self, + _config: &ForgeConfig, + ) -> Result { + Ok(LifecycleStatus::AlreadyAvailable) + } + } + + struct FakeGit; + impl GitRemoteManager for FakeGit { + fn remote_url( + &self, + _repo: &LocalRepository, + _remote_name: &str, + ) -> Result, NotforgeError> { + Ok(None) + } + + fn set_remote( + &self, + _repo: &LocalRepository, + _remote_name: &str, + _url: &str, + ) -> Result { + Ok(RemoteStatus::Added) + } + + fn push( + &self, + _repo: &LocalRepository, + _remote_name: &str, + _branch: &str, + _set_upstream: bool, + ) -> Result { + Ok(PushStatus::Pushed) + } + } + + struct FakeSecrets; + impl SecretResolverPort for FakeSecrets { + fn resolve(&self, _secret: &ForgeSecretRef) -> Result { + Ok("secret".to_string()) + } + } + + let api: &dyn ForgeApi = &FakeApi; + let lifecycle: &dyn ForgeLifecycle = &FakeLifecycle; + let git: &dyn GitRemoteManager = &FakeGit; + let secrets: &dyn SecretResolverPort = &FakeSecrets; + + assert_eq!(api.version().unwrap().version, "1.0.0"); + assert_eq!( + lifecycle + .ensure_available(&ForgeConfig { + gitea: notforge::config::GiteaConfig { + base_url: "http://localhost:3000".to_string(), + owner: "joe".to_string(), + ssh_host: "localhost".to_string(), + ssh_port: 2222, + mode: GiteaMode::Existing, + auth: ForgeAuthConfig::default(), + }, + repositories: Vec::new(), + }) + .unwrap(), + LifecycleStatus::AlreadyAvailable + ); + assert_eq!( + git.remote_url( + &LocalRepository { + path: PathBuf::from(".") + }, + "gitea" + ) + .unwrap(), + None + ); + assert_eq!( + secrets + .resolve(&ForgeSecretRef::Env { + key: "GITEA_TOKEN".to_string() + }) + .unwrap(), + "secret" + ); +} diff --git a/crates/notsecrets/src/config.rs b/crates/notsecrets/src/config.rs index cd685b6..09c5b40 100644 --- a/crates/notsecrets/src/config.rs +++ b/crates/notsecrets/src/config.rs @@ -79,9 +79,11 @@ pub struct SecretsConfig { } pub fn load_config(path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|e| SecretsError::Config(format!("cannot read {}: {e}", path.display())))?; - toml::from_str(&content).map_err(|e| SecretsError::Config(format!("parse error: {e}"))) + notcore::config::load_toml_file( + path, + |path, err| SecretsError::Config(format!("cannot read {}: {err}", path.display())), + |_path, err| SecretsError::Config(format!("parse error: {err}")), + ) } #[cfg(test)]