use anyhow::{Context as _, Result, bail}; use clap::Parser as _; use tracing::{info, warn}; use tracing_subscriber::EnvFilter; use witryna::cli::{Cli, Command}; use witryna::config; use witryna::logs::{self, DeploymentStatus}; use witryna::{pipeline, server}; #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); let config_path = config::discover_config(cli.config.as_deref())?; match cli.command { Command::Serve => run_serve(config_path).await, Command::Validate => run_validate(config_path).await, Command::Run { site, verbose } => run_run(config_path, site, verbose).await, Command::Status { site, json } => run_status(config_path, site, json).await, } } async fn run_serve(config_path: std::path::PathBuf) -> Result<()> { let config = config::Config::load(&config_path).await?; // Initialize tracing with configured log level // RUST_LOG env var takes precedence if set let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(config.log_level_filter().to_string())); tracing_subscriber::fmt().with_env_filter(filter).init(); info!( listen_address = %config.listen_address, container_runtime = %config.container_runtime, base_dir = %config.base_dir.display(), log_dir = %config.log_dir.display(), log_level = %config.log_level, sites_count = config.sites.len(), "loaded configuration" ); for site in &config.sites { if site.webhook_token.is_empty() { warn!( name = %site.name, "webhook authentication disabled (no token configured)" ); } if let Some(interval) = site.poll_interval { info!( name = %site.name, repo_url = %site.repo_url, branch = %site.branch, poll_interval_secs = interval.as_secs(), "configured site with polling" ); } else { info!( name = %site.name, repo_url = %site.repo_url, branch = %site.branch, "configured site (webhook-only)" ); } } server::run(config, config_path).await } #[allow(clippy::print_stderr)] // CLI validation output goes to stderr async fn run_validate(config_path: std::path::PathBuf) -> Result<()> { let config = config::Config::load(&config_path).await?; eprintln!("{}", format_validate_summary(&config, &config_path)); Ok(()) } #[allow(clippy::print_stderr)] // CLI output goes to stderr async fn run_run(config_path: std::path::PathBuf, site_name: String, verbose: bool) -> Result<()> { let config = config::Config::load(&config_path).await?; let site = config .find_site(&site_name) .with_context(|| { format!( "site '{}' not found in {}", site_name, config_path.display() ) })? .clone(); // Initialize tracing: compact stderr, DEBUG when verbose let level = if verbose { "debug" } else { "info" }; let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); tracing_subscriber::fmt() .with_env_filter(filter) .with_writer(std::io::stderr) .init(); eprintln!( "Building site: {} (repo: {}, branch: {})", site_name, site.repo_url, site.branch ); let git_timeout = config .git_timeout .unwrap_or(witryna::git::GIT_TIMEOUT_DEFAULT); let result = pipeline::run_build( &site_name, &site, &config.base_dir, &config.log_dir, &config.container_runtime, config.max_builds_to_keep, git_timeout, verbose, ) .await?; eprintln!( "Build succeeded in {} — {}", logs::format_duration(result.duration), result.build_dir.display(), ); eprintln!("Log: {}", result.log_file.display()); Ok(()) } #[allow(clippy::print_stdout)] // CLI status output goes to stdout (pipeable) async fn run_status( config_path: std::path::PathBuf, site_filter: Option, json: bool, ) -> Result<()> { let config = config::Config::load(&config_path).await?; if let Some(name) = &site_filter && config.find_site(name).is_none() { bail!("site '{}' not found in {}", name, config_path.display()); } let mut statuses: Vec = Vec::new(); match &site_filter { Some(name) => { // Show last 10 deployments for a single site let site_logs = logs::list_site_logs(&config.log_dir, name).await?; for (ts, path) in site_logs.into_iter().take(10) { let ds = logs::get_deployment_status(&config.log_dir, name, &ts, &path).await?; statuses.push(ds); } } None => { // Show latest deployment for each site for site in &config.sites { let site_logs = logs::list_site_logs(&config.log_dir, &site.name).await?; if let Some((ts, path)) = site_logs.into_iter().next() { let ds = logs::get_deployment_status(&config.log_dir, &site.name, &ts, &path) .await?; statuses.push(ds); } else { statuses.push(DeploymentStatus { site_name: site.name.clone(), timestamp: "-".to_owned(), git_commit: "-".to_owned(), duration: "-".to_owned(), status: "-".to_owned(), log: "(no builds)".to_owned(), }); } } } } if json { #[allow(clippy::expect_used)] // DeploymentStatus serialization cannot fail let output = serde_json::to_string_pretty(&statuses) .expect("DeploymentStatus serialization cannot fail"); println!("{output}"); } else { print!("{}", format_status_table(&statuses)); } Ok(()) } fn format_status_table(statuses: &[DeploymentStatus]) -> String { use std::fmt::Write as _; let site_width = statuses .iter() .map(|s| s.site_name.len()) .max() .unwrap_or(4) .max(4); let mut out = String::new(); let _ = writeln!( out, "{: String { use std::fmt::Write as _; let mut out = String::new(); let _ = writeln!(out, "Configuration valid: {}", path.display()); let _ = writeln!(out, " Listen: {}", config.listen_address); let _ = writeln!(out, " Runtime: {}", config.container_runtime); let _ = write!(out, " Sites: {}", config.sites.len()); for site in &config.sites { let _ = write!( out, "\n - {} ({}, branch: {})", site.name, site.repo_url, site.branch ); } out } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::indexing_slicing)] mod tests { use super::*; use std::path::PathBuf; use witryna::config::{BuildOverrides, Config, SiteConfig}; use witryna::logs::DeploymentStatus; fn test_config(sites: Vec) -> Config { Config { listen_address: "127.0.0.1:8080".to_owned(), container_runtime: "podman".to_owned(), base_dir: PathBuf::from("/var/lib/witryna"), log_dir: PathBuf::from("/var/log/witryna"), log_level: "info".to_owned(), rate_limit_per_minute: 10, max_builds_to_keep: 5, git_timeout: None, sites, } } fn test_site(name: &str, repo_url: &str, branch: &str) -> SiteConfig { SiteConfig { name: name.to_owned(), repo_url: repo_url.to_owned(), branch: branch.to_owned(), webhook_token: "token".to_owned(), webhook_token_file: None, build_overrides: BuildOverrides::default(), poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: None, container_memory: None, container_cpus: None, container_pids_limit: None, container_network: "none".to_owned(), git_depth: None, container_workdir: None, config_file: None, } } #[test] fn validate_summary_single_site() { let config = test_config(vec![test_site( "my-site", "https://github.com/user/my-site.git", "main", )]); let output = format_validate_summary(&config, &PathBuf::from("witryna.toml")); assert!(output.contains("Configuration valid: witryna.toml")); assert!(output.contains("Listen: 127.0.0.1:8080")); assert!(output.contains("Runtime: podman")); assert!(output.contains("Sites: 1")); assert!(output.contains("my-site (https://github.com/user/my-site.git, branch: main)")); } #[test] fn validate_summary_multiple_sites() { let config = test_config(vec![ test_site("site-one", "https://github.com/user/site-one.git", "main"), test_site( "site-two", "https://github.com/user/site-two.git", "develop", ), ]); let output = format_validate_summary(&config, &PathBuf::from("/etc/witryna.toml")); assert!(output.contains("Sites: 2")); assert!(output.contains("site-one (https://github.com/user/site-one.git, branch: main)")); assert!( output.contains("site-two (https://github.com/user/site-two.git, branch: develop)") ); } #[test] fn validate_summary_no_sites() { let config = test_config(vec![]); let output = format_validate_summary(&config, &PathBuf::from("witryna.toml")); assert!(output.contains("Sites: 0")); assert!(!output.contains(" -")); } #[test] fn validate_summary_runtime_shows_value() { let config = test_config(vec![]); let output = format_validate_summary(&config, &PathBuf::from("witryna.toml")); assert!(output.contains("Runtime: podman")); } // --- format_status_table tests --- fn test_deployment( site_name: &str, status: &str, commit: &str, duration: &str, timestamp: &str, log: &str, ) -> DeploymentStatus { DeploymentStatus { site_name: site_name.to_owned(), timestamp: timestamp.to_owned(), git_commit: commit.to_owned(), duration: duration.to_owned(), status: status.to_owned(), log: log.to_owned(), } } #[test] fn format_status_table_single_site_success() { let statuses = vec![test_deployment( "my-site", "success", "abc123d", "45s", "20260126-143000-123456", "/var/log/witryna/my-site/20260126-143000-123456.log", )]; let output = format_status_table(&statuses); assert!(output.contains("SITE")); assert!(output.contains("STATUS")); assert!(output.contains("my-site")); assert!(output.contains("success")); assert!(output.contains("abc123d")); assert!(output.contains("45s")); } #[test] fn format_status_table_no_builds() { let statuses = vec![test_deployment( "empty-site", "-", "-", "-", "-", "(no builds)", )]; let output = format_status_table(&statuses); assert!(output.contains("empty-site")); assert!(output.contains("(no builds)")); } #[test] fn format_status_table_multiple_sites() { let statuses = vec![ test_deployment( "site-one", "success", "abc123d", "45s", "20260126-143000-123456", "/logs/site-one/20260126-143000-123456.log", ), test_deployment( "site-two", "failed", "def456", "2m 0s", "20260126-160000-000000", "/logs/site-two/20260126-160000-000000.log", ), ]; let output = format_status_table(&statuses); assert!(output.contains("site-one")); assert!(output.contains("site-two")); assert!(output.contains("success")); assert!(output.contains("failed")); } #[test] fn format_status_table_hook_failed() { let statuses = vec![test_deployment( "hook-site", "hook failed", "abc123d", "12s", "20260126-143000-123456", "/logs/hook-site/20260126-143000-123456.log", )]; let output = format_status_table(&statuses); assert!(output.contains("hook failed")); } }