#![expect(
clippy::exit,
reason = "Allow exits because in this file we ideally handle all errors with known exit codes"
)]
#![expect(
clippy::module_name_repetitions,
reason = "This is a CLI module, so it is expected to have the same name as the crate"
)]
pub use crate::history::changes;
pub use crate::server::app::serve_archive;
pub use crate::server::errors::CliError;
pub use crate::server::git::serve_git;
pub use crate::utils::archive::find_archive_path;
use clap::Parser;
use std::env;
use std::path::Path;
use std::path::PathBuf;
use std::process;
use tracing;
use tracing::Level;
use tracing_appender::rolling;
use tracing_subscriber::fmt;
use tracing_subscriber::fmt::writer::MakeWriterExt as _;
use tracing_subscriber::Layer as _;
use tracing_subscriber::{
filter::EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _,
};
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
#[arg(short, long, default_value_t = String::from(".").to_owned())]
archive_path: String,
#[arg(short = 'l', long = "log-location")]
log_path: Option<String>,
#[command(subcommand)]
subcommands: Subcommands,
}
#[derive(Clone, Debug)]
pub enum StelaeSubcommands {
Git {
port: u16,
},
Serve {
port: u16,
individual: bool,
bind_to: String,
},
Update {
include: Vec<String>,
exclude: Vec<String>,
},
}
pub trait CliProvider {
fn archive_path(&self) -> &str;
fn subcommand(&self) -> StelaeSubcommands;
}
impl CliProvider for Cli {
fn archive_path(&self) -> &str {
&self.archive_path
}
#[expect(
clippy::pattern_type_mismatch,
reason = "Matching on a reference (&cli.subcommands) instead of by value; the match patterns borrow fields, which is intentional to avoid moving data."
)]
fn subcommand(&self) -> StelaeSubcommands {
match &self.subcommands {
Subcommands::Git { port } => StelaeSubcommands::Git { port: *port },
Subcommands::Serve {
port,
individual,
bind_to,
} => StelaeSubcommands::Serve {
port: *port,
individual: *individual,
bind_to: bind_to.clone(),
},
Subcommands::Update { include, exclude } => StelaeSubcommands::Update {
include: include.clone(),
exclude: exclude.clone(),
},
}
}
}
#[derive(Clone, clap::Subcommand)]
pub enum Subcommands {
Git {
#[arg(short, long, default_value_t = 8080)]
port: u16,
},
Serve {
#[arg(short, long, default_value_t = 8080)]
port: u16,
#[arg(short, long, default_value_t = false)]
individual: bool,
#[arg(short, long, default_value = "127.0.0.1")]
bind_to: String,
},
Update {
#[arg(short = 'i', long = "include", num_args(1..))]
include: Vec<String>,
#[arg(short = 'e', long = "exclude", num_args(1..))]
exclude: Vec<String>,
},
}
#[expect(
clippy::expect_used,
reason = "Expect that console logging can be initialized"
)]
pub fn init_tracing(archive_path: &Path, log_path: &Option<String>) {
let log_dir: PathBuf = log_path
.as_ref()
.map_or_else(|| archive_path.join(".taf"), PathBuf::from);
let debug_file_appender =
rolling::never(&log_dir, "stelae-debug.log").with_max_level(Level::DEBUG);
let error_file_appender =
rolling::never(&log_dir, "stelae-error.log").with_max_level(Level::WARN);
let mut debug_layer = fmt::layer().with_writer(debug_file_appender);
let mut error_layer = fmt::layer().with_writer(error_file_appender);
debug_layer = debug_layer.with_ansi(false);
error_layer = error_layer.with_ansi(false);
let console_layer = fmt::layer().with_target(true).with_filter(
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))
.expect("Failed to initialize console logging"),
);
tracing_subscriber::registry()
.with(debug_layer)
.with(error_layer)
.with(console_layer)
.init();
if env::var("RUST_LOG").is_err() {
env::set_var("RUST_LOG", "info");
}
}
#[expect(
clippy::pattern_type_mismatch,
reason = "Matching on a reference (&cli.subcommands) instead of by value; the match patterns borrow fields, which is intentional to avoid moving data."
)]
pub fn execute_command<T: CliProvider>(cli: &T, archive_path: PathBuf) -> Result<(), CliError> {
match &cli.subcommand() {
StelaeSubcommands::Git { port } => serve_git(cli.archive_path(), archive_path, *port),
StelaeSubcommands::Serve {
port,
individual,
bind_to,
} => serve_archive(
cli.archive_path(),
archive_path,
*port,
*individual,
bind_to,
),
StelaeSubcommands::Update { include, exclude } => {
changes::insert(cli.archive_path(), archive_path, include, exclude)
}
}
}
pub fn run() {
tracing::debug!("Starting application");
let cli = Cli::parse();
let archive_path_wd = Path::new(&cli.archive_path);
let Ok(archive_path) = find_archive_path(archive_path_wd) else {
tracing::error!(
"error: could not find `.taf` folder in `{}` or any parent directory",
&cli.archive_path
);
process::exit(1);
};
let log_path = cli.log_path.clone();
init_tracing(&archive_path, &log_path);
match execute_command(&cli, archive_path) {
Ok(()) => process::exit(0),
Err(err) => {
tracing::error!("Application error: {err:?}");
process::exit(1);
}
}
}