From 040f1a8a56062c20696a1c992f6f1f567e827efb Mon Sep 17 00:00:00 2001 From: kjuulh Date: Thu, 13 Feb 2025 22:57:04 +0100 Subject: [PATCH] feat: add basic plan support --- .gitignore | 1 + Cargo.lock | 29 ++++++++++ crates/forest/Cargo.toml | 1 + crates/forest/src/cli.rs | 86 +++------------------------- crates/forest/src/main.rs | 2 + crates/forest/src/model.rs | 77 +++++++++++++++++++++++++ crates/forest/src/plan_reconciler.rs | 84 +++++++++++++++++++++++++++ examples/plan/forest.kdl | 3 + 8 files changed, 204 insertions(+), 79 deletions(-) create mode 100644 crates/forest/src/model.rs create mode 100644 crates/forest/src/plan_reconciler.rs create mode 100644 examples/plan/forest.kdl diff --git a/.gitignore b/.gitignore index 9c4c004..b94f5e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ .cuddle/ +.forest/ diff --git a/Cargo.lock b/Cargo.lock index af73036..e25548b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,6 +244,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "walkdir", ] [[package]] @@ -773,6 +774,15 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1122,6 +1132,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1153,6 +1173,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/crates/forest/Cargo.toml b/crates/forest/Cargo.toml index 0122329..68974ce 100644 --- a/crates/forest/Cargo.toml +++ b/crates/forest/Cargo.toml @@ -16,3 +16,4 @@ uuid.workspace = true rusty-s3 = "0.7.0" url = "2.5.4" kdl = "6.3.3" +walkdir = "2.5.0" diff --git a/crates/forest/src/cli.rs b/crates/forest/src/cli.rs index b481865..518f616 100644 --- a/crates/forest/src/cli.rs +++ b/crates/forest/src/cli.rs @@ -1,10 +1,10 @@ use std::{net::SocketAddr, path::PathBuf}; use clap::{Parser, Subcommand}; -use kdl::{KdlDocument, KdlNode, KdlValue}; +use kdl::KdlDocument; use rusty_s3::{Bucket, Credentials, S3Action}; -use crate::state::SharedState; +use crate::{model::Project, plan_reconciler::PlanReconciler, state::SharedState}; #[derive(Parser)] #[command(author, version, about, long_about = None, subcommand_required = true)] @@ -45,80 +45,6 @@ enum Commands { }, } -#[derive(Debug, Clone)] -pub enum ProjectPlan { - Local { path: PathBuf }, - NoPlan, -} - -impl TryFrom<&KdlNode> for ProjectPlan { - type Error = anyhow::Error; - - fn try_from(value: &KdlNode) -> Result { - let Some(children) = value.children() else { - return Ok(Self::NoPlan); - }; - - if let Some(local) = children.get_arg("local") { - return Ok(Self::Local { - path: local - .as_string() - .map(|l| l.to_string()) - .ok_or(anyhow::anyhow!("local must have an arg with a valid path"))? - .into(), - }); - } - - Ok(Self::NoPlan) - } -} - -#[derive(Debug, Clone)] -pub struct Project { - name: String, - description: Option, - plan: Option, -} - -impl TryFrom for Project { - type Error = anyhow::Error; - - fn try_from(value: KdlDocument) -> Result { - let project_section = value.get("project").ok_or(anyhow::anyhow!( - "forest.kdl project file must have a project object" - ))?; - - let project_children = project_section - .children() - .ok_or(anyhow::anyhow!("a forest project must have children"))?; - - let project_plan: Option = if let Some(project) = project_children.get("plan") - { - Some(project.try_into()?) - } else { - None - }; - - Ok(Self { - name: project_children - .get_arg("name") - .and_then(|n| match n { - KdlValue::String(s) => Some(s), - _ => None, - }) - .cloned() - .ok_or(anyhow::anyhow!("a forest kuddle project must have a name"))?, - description: project_children - .get_arg("description") - .and_then(|n| match n { - KdlValue::String(s) => Some(s.trim().to_string()), - _ => None, - }), - plan: project_plan, - }) - } -} - pub async fn execute() -> anyhow::Result<()> { let cli = Command::parse(); @@ -134,12 +60,15 @@ pub async fn execute() -> anyhow::Result<()> { ); } - let project_file = tokio::fs::read_to_string(project_file_path).await?; + let project_file = tokio::fs::read_to_string(&project_file_path).await?; let project_doc: KdlDocument = project_file.parse()?; let project: Project = project_doc.try_into()?; - tracing::trace!("found a project name: {}, {:?}", project.name, project); + + PlanReconciler::new() + .reconcile(&project, &project_path) + .await?; } Commands::Serve { @@ -162,7 +91,6 @@ pub async fn execute() -> anyhow::Result<()> { let _url = put_object.sign(std::time::Duration::from_secs(30)); let _state = SharedState::new().await?; } - _ => (), } Ok(()) diff --git a/crates/forest/src/main.rs b/crates/forest/src/main.rs index 08a279a..c22c7fc 100644 --- a/crates/forest/src/main.rs +++ b/crates/forest/src/main.rs @@ -1,4 +1,6 @@ pub mod cli; +pub mod model; +pub mod plan_reconciler; pub mod state; #[tokio::main] diff --git a/crates/forest/src/model.rs b/crates/forest/src/model.rs new file mode 100644 index 0000000..21e2b60 --- /dev/null +++ b/crates/forest/src/model.rs @@ -0,0 +1,77 @@ +use std::path::PathBuf; + +use kdl::{KdlDocument, KdlNode, KdlValue}; + +#[derive(Debug, Clone)] +pub enum ProjectPlan { + Local { path: PathBuf }, + NoPlan, +} + +impl TryFrom<&KdlNode> for ProjectPlan { + type Error = anyhow::Error; + + fn try_from(value: &KdlNode) -> Result { + let Some(children) = value.children() else { + return Ok(Self::NoPlan); + }; + + if let Some(local) = children.get_arg("local") { + return Ok(Self::Local { + path: local + .as_string() + .map(|l| l.to_string()) + .ok_or(anyhow::anyhow!("local must have an arg with a valid path"))? + .into(), + }); + } + + Ok(Self::NoPlan) + } +} + +#[derive(Debug, Clone)] +pub struct Project { + pub name: String, + pub description: Option, + pub plan: Option, +} + +impl TryFrom for Project { + type Error = anyhow::Error; + + fn try_from(value: KdlDocument) -> Result { + let project_section = value.get("project").ok_or(anyhow::anyhow!( + "forest.kdl project file must have a project object" + ))?; + + let project_children = project_section + .children() + .ok_or(anyhow::anyhow!("a forest project must have children"))?; + + let project_plan: Option = if let Some(project) = project_children.get("plan") + { + Some(project.try_into()?) + } else { + None + }; + + Ok(Self { + name: project_children + .get_arg("name") + .and_then(|n| match n { + KdlValue::String(s) => Some(s), + _ => None, + }) + .cloned() + .ok_or(anyhow::anyhow!("a forest kuddle project must have a name"))?, + description: project_children + .get_arg("description") + .and_then(|n| match n { + KdlValue::String(s) => Some(s.trim().to_string()), + _ => None, + }), + plan: project_plan, + }) + } +} diff --git a/crates/forest/src/plan_reconciler.rs b/crates/forest/src/plan_reconciler.rs new file mode 100644 index 0000000..70ca4d9 --- /dev/null +++ b/crates/forest/src/plan_reconciler.rs @@ -0,0 +1,84 @@ +use std::path::Path; + +use anyhow::Context; + +use crate::model::Project; + +pub mod local { + use std::path::Path; + + use anyhow::Context; + + pub async fn reconcile(source: &Path, dest: &Path) -> anyhow::Result<()> { + for entry in walkdir::WalkDir::new(source) { + let entry = entry?; + let rel = entry.path().strip_prefix(source)?; + let metadata = entry.metadata()?; + + if metadata.is_file() { + tracing::trace!("copying file: {}", rel.display()); + let dest_path = dest.join(rel); + + tokio::fs::copy(entry.path(), &dest_path) + .await + .context(anyhow::anyhow!( + "failed to file directory at: {}", + dest_path.display() + ))?; + } else if metadata.is_dir() { + let dest_path = dest.join(rel); + + tracing::trace!("creating directory: {}", dest_path.display()); + tokio::fs::create_dir_all(&dest_path) + .await + .context(anyhow::anyhow!( + "failed to create directory at: {}", + dest_path.display() + ))?; + } + } + + Ok(()) + } +} + +#[derive(Default)] +pub struct PlanReconciler {} + +impl PlanReconciler { + pub fn new() -> Self { + Self {} + } + + pub async fn reconcile(&self, project: &Project, destination: &Path) -> anyhow::Result<()> { + tracing::info!("reconciling project"); + if project.plan.is_none() { + tracing::debug!("no plan, returning"); + return Ok(()); + } + + // prepare the plan dir + // TODO: We're always deleting, consider some form of caching + let plan_dir = destination.join(".forest").join("plan"); + if plan_dir.exists() { + tokio::fs::remove_dir_all(&plan_dir).await?; + } + tokio::fs::create_dir_all(&plan_dir) + .await + .context(anyhow::anyhow!( + "failed to create plan dir: {}", + plan_dir.display() + ))?; + + match project.plan.as_ref().unwrap() { + crate::model::ProjectPlan::Local { path } => { + let source = &destination.join(path); + local::reconcile(source, &plan_dir).await?; + } + crate::model::ProjectPlan::NoPlan => { + tracing::debug!("no plan, returning") + } + } + Ok(()) + } +} diff --git a/examples/plan/forest.kdl b/examples/plan/forest.kdl new file mode 100644 index 0000000..7c91a55 --- /dev/null +++ b/examples/plan/forest.kdl @@ -0,0 +1,3 @@ +plan { + name project +}