feat: add basic plan support

This commit is contained in:
2025-02-13 22:57:04 +01:00
parent a4565c7c12
commit 040f1a8a56
8 changed files with 204 additions and 79 deletions

View File

@@ -16,3 +16,4 @@ uuid.workspace = true
rusty-s3 = "0.7.0"
url = "2.5.4"
kdl = "6.3.3"
walkdir = "2.5.0"

View File

@@ -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<Self, Self::Error> {
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<String>,
plan: Option<ProjectPlan>,
}
impl TryFrom<KdlDocument> for Project {
type Error = anyhow::Error;
fn try_from(value: KdlDocument) -> Result<Self, Self::Error> {
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<ProjectPlan> = 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(())

View File

@@ -1,4 +1,6 @@
pub mod cli;
pub mod model;
pub mod plan_reconciler;
pub mod state;
#[tokio::main]

View File

@@ -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<Self, Self::Error> {
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<String>,
pub plan: Option<ProjectPlan>,
}
impl TryFrom<KdlDocument> for Project {
type Error = anyhow::Error;
fn try_from(value: KdlDocument) -> Result<Self, Self::Error> {
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<ProjectPlan> = 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,
})
}
}

View File

@@ -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(())
}
}