use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; use std::sync::Arc; use tokio::net::TcpListener; use yoi_workspace_server::{ SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceIdentity, serve, }; #[derive(Debug)] enum Command { Serve(ServeOptions), Init(InitOptions), ConfigDefault, ConfigDiff(WorkspacePathOptions), Help, } #[derive(Debug)] struct ServeOptions { workspace: PathBuf, db: Option, frontend: Option, listen: Option, } #[derive(Debug)] struct InitOptions { workspace: PathBuf, } #[derive(Debug)] struct WorkspacePathOptions { workspace: PathBuf, } #[derive(Debug)] struct CliError(String); impl std::fmt::Display for CliError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } impl std::error::Error for CliError {} #[tokio::main] async fn main() -> ExitCode { match run().await { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("yoi-workspace-server: {error}"); ExitCode::FAILURE } } } async fn run() -> Result<(), Box> { let args = std::env::args().skip(1).collect::>(); match parse_command(&args)? { Command::Serve(options) => run_serve(options).await, Command::Init(options) => run_init(options), Command::ConfigDefault => run_config_default(), Command::ConfigDiff(options) => run_config_diff(options), Command::Help => Ok(()), } } fn parse_command(args: &[String]) -> Result { let Some((command, rest)) = args.split_first() else { print_help(); return Ok(Command::Help); }; match command.as_str() { "init" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { print_init_help(); return Ok(Command::Help); } Ok(Command::Init(parse_init_options(rest)?)) } "config" => parse_config_command(rest), "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { print_serve_help(); return Ok(Command::Help); } Ok(Command::Serve(parse_serve_options(rest)?)) } "--help" | "-h" => { print_help(); Ok(Command::Help) } other => Err(CliError(format!( "unknown command `{other}`; expected `init`, `config`, or `serve`" ))), } } fn run_init(options: InitOptions) -> Result<(), Box> { let identity = WorkspaceIdentity::load_or_init(&options.workspace)?; WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?; eprintln!( "yoi-workspace-server: initialized workspace `{}` ({})", options.workspace.display(), identity.workspace_id ); Ok(()) } fn run_config_default() -> Result<(), Box> { print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}"); Ok(()) } fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box> { let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?; print!("{}", diff.text); Ok(()) } async fn run_serve(options: ServeOptions) -> Result<(), Box> { let identity = WorkspaceIdentity::load_required(&options.workspace)?; let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?; let mut resolved = config_file.resolve(&options.workspace, identity)?; if let Some(db) = options.db { resolved = resolved.with_database_path(db); } if let Some(frontend) = options.frontend { resolved = resolved.with_static_assets_dir(Some(frontend)); } if let Some(listen) = options.listen { resolved = resolved.with_listen(listen); } if let Some(parent) = resolved.database_path.parent() { tokio::fs::create_dir_all(parent).await?; } let store = Arc::new(SqliteWorkspaceStore::open(&resolved.database_path)?); let listener = TcpListener::bind(resolved.listen).await?; eprintln!( "yoi-workspace-server: serving workspace `{}` on http://{}", options.workspace.display(), listener.local_addr()? ); serve(resolved.server, store, listener).await?; Ok(()) } fn parse_config_command(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { print_config_help(); return Ok(Command::Help); }; match subcommand.as_str() { "default" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { print_config_help(); return Ok(Command::Help); } if !rest.is_empty() { return Err(CliError( "config default does not accept options".to_string(), )); } Ok(Command::ConfigDefault) } "diff" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { print_config_help(); return Ok(Command::Help); } Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?)) } "--help" | "-h" => { print_config_help(); Ok(Command::Help) } other => Err(CliError(format!( "unknown config subcommand `{other}`; expected `default` or `diff`" ))), } } fn parse_workspace_path_options(args: &[String]) -> Result { let mut workspace = std::env::current_dir() .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { "--workspace" => { let value = iter .next() .ok_or_else(|| CliError("--workspace requires a path".to_string()))?; workspace = PathBuf::from(value); } value if value.starts_with("--workspace=") => { workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); } other => return Err(CliError(format!("unknown workspace option `{other}`"))), } } let workspace = workspace .canonicalize() .map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?; Ok(WorkspacePathOptions { workspace }) } fn parse_init_options(args: &[String]) -> Result { let mut workspace = std::env::current_dir() .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { "--workspace" => { let value = iter .next() .ok_or_else(|| CliError("--workspace requires a path".to_string()))?; workspace = PathBuf::from(value); } value if value.starts_with("--workspace=") => { workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); } other => return Err(CliError(format!("unknown init option `{other}`"))), } } let workspace = workspace .canonicalize() .map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?; Ok(InitOptions { workspace }) } fn parse_serve_options(args: &[String]) -> Result { let mut workspace = std::env::current_dir() .map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?; let mut db = None; let mut frontend = None; let mut listen = None; let mut index = 0; while index < args.len() { let arg = &args[index]; match arg.as_str() { "--workspace" => { index += 1; let value = args .get(index) .ok_or_else(|| CliError("--workspace requires a value".to_string()))?; workspace = PathBuf::from(value); } "--db" => { index += 1; let value = args .get(index) .ok_or_else(|| CliError("--db requires a value".to_string()))?; db = Some(PathBuf::from(value)); } "--frontend" => { index += 1; let value = args .get(index) .ok_or_else(|| CliError("--frontend requires a value".to_string()))?; frontend = Some(PathBuf::from(value)); } "--listen" => { index += 1; let value = args .get(index) .ok_or_else(|| CliError("--listen requires a value".to_string()))?; listen = Some(parse_listen(value)?); } _ if arg.starts_with("--workspace=") => { workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); } _ if arg.starts_with("--db=") => { db = Some(PathBuf::from(value_after_equals(arg, "--db")?)); } _ if arg.starts_with("--frontend=") => { frontend = Some(PathBuf::from(value_after_equals(arg, "--frontend")?)); } _ if arg.starts_with("--listen=") => { listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?); } _ if arg.starts_with('-') => { return Err(CliError(format!("unknown serve option `{arg}`"))); } _ => { return Err(CliError(format!( "unexpected positional argument `{arg}`; use --workspace " ))); } } index += 1; } let workspace = workspace.canonicalize().map_err(|error| { CliError(format!( "failed to canonicalize workspace `{}`: {error}", workspace.display() )) })?; Ok(ServeOptions { workspace, db, frontend, listen, }) } fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> { let value = arg .strip_prefix(flag) .and_then(|rest| rest.strip_prefix('=')) .unwrap_or_default(); if value.is_empty() { return Err(CliError(format!("{flag} requires a value"))); } Ok(value) } fn parse_listen(value: &str) -> Result { value .parse() .map_err(|_| CliError(format!("invalid --listen address `{value}`"))) } fn print_help() { println!( "yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" ); } fn print_init_help() { println!( "yoi-workspace-server init\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity and copies the packaged Backend config template to .yoi/workspace-backend.local.toml. Does not create Backend data stores.\n\nOptions:\n --workspace Workspace root to initialize (defaults to cwd)\n -h, --help Print help" ); } fn print_config_help() { println!( "yoi-workspace-server config\n\nUsage:\n yoi-workspace-server config default\n yoi-workspace-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace Workspace root (defaults to cwd)\n -h, --help Print help" ); } fn print_serve_help() { println!( "yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace Workspace root containing .yoi project records (defaults to cwd)\n --db SQLite database path (legacy dev override)\n --frontend Static SPA build directory to serve (legacy dev override)\n --listen Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help" ); } #[cfg(test)] mod tests { use super::*; use yoi_workspace_server::{ WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WORKSPACE_IDENTITY_RELATIVE_PATH, }; #[test] fn parse_init_defaults_workspace_to_cwd_or_flag() { let temp = tempfile::tempdir().unwrap(); let args = vec!["--workspace".to_string(), temp.path().display().to_string()]; let options = parse_init_options(&args).unwrap(); assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); } #[test] fn init_creates_identity_and_local_config_only() { let temp = tempfile::tempdir().unwrap(); run_init(InitOptions { workspace: temp.path().canonicalize().unwrap(), }) .unwrap(); assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists()); let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH); assert!(local_config_path.exists()); assert_eq!( std::fs::read_to_string(local_config_path).unwrap(), WORKSPACE_BACKEND_CONFIG_TEMPLATE ); assert!( !temp .path() .join(".yoi/workspace-backend.default.toml") .exists() ); assert!(!temp.path().join(".yoi/workspace.db").exists()); assert!(!temp.path().join(".yoi/embedded-runtime").exists()); } }