use std::{net::SocketAddr, path::PathBuf}; use anyhow::Context as AnyContext; use clap::{FromArgMatches, Parser, Subcommand, crate_authors, crate_description, crate_version}; use colored_json::ToColoredJson; use kdl::KdlDocument; use rusty_s3::{Bucket, Credentials, S3Action}; use crate::{ model::{Context, ForestFile, Plan, Project, WorkspaceProject}, plan_reconciler::PlanReconciler, state::SharedState, }; mod run; mod template; #[derive(Subcommand)] enum Commands { Init {}, Template(template::Template), Info {}, Clean {}, Serve { #[arg(env = "FOREST_HOST", long, default_value = "127.0.0.1:3000")] host: SocketAddr, #[arg(env = "FOREST_S3_ENDPOINT", long = "s3-endpoint")] s3_endpoint: String, #[arg(env = "FOREST_S3_REGION", long = "s3-region")] s3_region: String, #[arg(env = "FOREST_S3_BUCKET", long = "s3-bucket")] s3_bucket: String, #[arg(env = "FOREST_S3_USER", long = "s3-user")] s3_user: String, #[arg(env = "FOREST_S3_PASSWORD", long = "s3-password")] s3_password: String, }, } fn get_root(include_run: bool) -> clap::Command { let mut root_cmd = clap::Command::new("forest") .subcommand_required(true) .author(crate_authors!()) .version(crate_version!()) .about(crate_description!()) .ignore_errors(include_run) .arg( clap::Arg::new("project_path") .long("project-path") .env("FOREST_PROJECT_PATH") .default_value("."), ); if include_run { root_cmd = root_cmd.subcommand(clap::Command::new("run").allow_external_subcommands(true)) } Commands::augment_subcommands(root_cmd) } pub async fn execute() -> anyhow::Result<()> { let matches = get_root(true).get_matches(); let project_path = PathBuf::from( &matches .get_one::("project_path") .expect("project path always to be set"), ) .canonicalize()?; let project_file_path = project_path.join("forest.kdl"); if !project_file_path.exists() { anyhow::bail!( "no 'forest.kdl' file was found at: {}", project_file_path.display().to_string() ); } let project_file = tokio::fs::read_to_string(&project_file_path).await?; let doc: KdlDocument = project_file.parse()?; let project: ForestFile = doc.try_into()?; match project { ForestFile::Workspace(workspace) => { tracing::trace!("running as workspace"); // 1. For each member load the project let mut workspace_members = Vec::new(); for member in workspace.members { let workspace_member_path = project_path.join(&member.path); let project_file_path = workspace_member_path.join("forest.kdl"); if !project_file_path.exists() { anyhow::bail!( "no 'forest.kdl' file was found at: {}", workspace_member_path.display().to_string() ); } let project_file = tokio::fs::read_to_string(&project_file_path).await?; let doc: KdlDocument = project_file.parse()?; let project: WorkspaceProject = doc.try_into().context(format!( "workspace member: {} failed to parse", &member.path ))?; workspace_members.push((workspace_member_path, project)); } // TODO: 1a (optional). Resolve dependencies // 2. Reconcile plans let mut member_contexts = Vec::new(); for (member_path, member) in &workspace_members { match member { WorkspaceProject::Plan(_plan) => { tracing::warn!("skipping reconcile for plans for now") } WorkspaceProject::Project(project) => { let plan = if let Some(plan_file_path) = PlanReconciler::new() .reconcile( project, member_path, Some(workspace_members.as_ref()), Some(&project_path), ) .await? { let plan_file = tokio::fs::read_to_string(&plan_file_path) .await .context(format!( "failed to read file at: {}", project_path.to_string_lossy() ))?; let plan_doc: KdlDocument = plan_file.parse()?; let plan: Plan = plan_doc.try_into()?; tracing::trace!("found a plan name: {}", project.name); Some(plan) } else { None }; let context = Context { project: project.clone(), plan, }; member_contexts.push((member_path, context)); } } } tracing::debug!("run is called, building extra commands, rerunning the parser"); let mut run_cmd = clap::Command::new("run").subcommand_required(true); // 3. Provide context and aggregated commands for projects for (_, context) in &member_contexts { let commands = run::Run::augment_workspace_command(context, &context.project.name); run_cmd = run_cmd.subcommands(commands); } run_cmd = run_cmd.subcommand(clap::Command::new("all").allow_external_subcommands(true)); let mut root = get_root(false).subcommand(run_cmd); let matches = root.get_matches_mut(); if matches.subcommand().is_none() { root.print_help()?; anyhow::bail!("failed to find command"); } match matches .subcommand() .expect("forest requires a command to be passed") { ("run", args) => { let (run_args, args) = args.subcommand().expect("run must have subcommands"); match run_args { "all" => { let (all_cmd, _args) = args .subcommand() .expect("to be able to get a subcommand (todo: might not work)"); for (member_path, context) in member_contexts { run::Run::execute_command_if_exists(all_cmd, member_path, &context) .await?; } } _ => { let (project_name, command) = run_args .split_once("::") .expect("commands to always be pairs for workspaces"); let mut found_context = false; for (member_path, context) in &member_contexts { if project_name == context.project.name { run::Run::execute_command(command, member_path, context) .await?; found_context = true; } } if !found_context { anyhow::bail!("no matching context was found") } } } } _ => match Commands::from_arg_matches(&matches).unwrap() { Commands::Init {} => { tracing::info!("initializing project"); } Commands::Info {} => { let output = serde_json::to_string_pretty(&member_contexts)?; println!("{}", output.to_colored_json_auto().unwrap_or(output)); } Commands::Template(template) => { //template.execute(&project_path, &context).await?; } Commands::Serve { s3_endpoint, s3_bucket, s3_region, s3_user, s3_password, .. } => { tracing::info!("Starting server"); let creds = Credentials::new(s3_user, s3_password); let bucket = Bucket::new( url::Url::parse(&s3_endpoint)?, rusty_s3::UrlStyle::Path, s3_bucket, s3_region, )?; let put_object = bucket.put_object(Some(&creds), "some-object"); let _url = put_object.sign(std::time::Duration::from_secs(30)); let _state = SharedState::new().await?; } Commands::Clean {} => { todo!(); // let forest_path = project_path.join(".forest"); // if forest_path.exists() { // tokio::fs::remove_dir_all(forest_path).await?; // tracing::info!("removed .forest"); // } } }, } } ForestFile::Project(project) => { tracing::trace!("found a project name: {}", project.name); let plan = if let Some(plan_file_path) = PlanReconciler::new() .reconcile(&project, &project_path, None, None) .await? { let plan_file = tokio::fs::read_to_string(&plan_file_path).await?; let plan_doc: KdlDocument = plan_file.parse()?; let plan: Plan = plan_doc.try_into()?; tracing::trace!("found a plan name: {}", project.name); Some(plan) } else { None }; let context = Context { project, plan }; let matches = if matches.subcommand_matches("run").is_some() { tracing::debug!("run is called, building extra commands, rerunning the parser"); let root = get_root(false); let run_cmd = run::Run::augment_command(&context); root.subcommand(run_cmd).get_matches() } else { matches }; match matches .subcommand() .expect("forest requires a command to be passed") { ("run", args) => { run::Run::execute(args, &project_path, &context).await?; } _ => match Commands::from_arg_matches(&matches).unwrap() { Commands::Init {} => { tracing::info!("initializing project"); tracing::trace!("found context: {:?}", context); } Commands::Info {} => { let output = serde_json::to_string_pretty(&context)?; println!("{}", output.to_colored_json_auto().unwrap_or(output)); } Commands::Template(template) => { template.execute(&project_path, &context).await?; } Commands::Serve { s3_endpoint, s3_bucket, s3_region, s3_user, s3_password, .. } => { tracing::info!("Starting server"); let creds = Credentials::new(s3_user, s3_password); let bucket = Bucket::new( url::Url::parse(&s3_endpoint)?, rusty_s3::UrlStyle::Path, s3_bucket, s3_region, )?; let put_object = bucket.put_object(Some(&creds), "some-object"); let _url = put_object.sign(std::time::Duration::from_secs(30)); let _state = SharedState::new().await?; } Commands::Clean {} => { let forest_path = project_path.join(".forest"); if forest_path.exists() { tokio::fs::remove_dir_all(forest_path).await?; tracing::info!("removed .forest"); } } }, } } } Ok(()) }