Files
honey-biscuit-workshop/workshop-baker/src/bake/builder.rs
T
Catty Steve 26809df720 feat(bake): DAG scheduler, decorator execution, notify refactor, workshop-schedule crate
- Add workshop-schedule crate (petgraph-based DAG scheduling)
- Implement all bake decorators with combinator chain execution
- Refactor notify module: extract handler, rule, template, util, queue
- Add NotificationQueue with priority/drain semantics
- Enhance finalize plugin system with internal plugin registration
- Update ExecutionContext and event types
- Add per-crate AGENTS.md knowledge base files
- Remove inline AGENTS.md files (consolidated into per-crate docs)
2026-06-15 20:45:06 +08:00

180 lines
5.5 KiB
Rust

use crate::bake::constant::PIPELINE_SKIP_ERRORCODE;
use crate::bake::decorator::Decorator;
use crate::bake::error::BakeError;
use crate::bake::parser::Function;
use minijinja::{Environment, context};
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::path::PathBuf;
use tempfile::NamedTempFile;
/// Three-level rendering control.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderMode {
/// No template, hardcoded "{{ main }}". Debug escape hatch.
Off,
/// Uses bake_base.sh with essential execution skeleton only.
Minimal,
/// Uses bake_base.sh with helpers (logs, colors, etc.).
Full,
}
impl RenderMode {
pub fn as_str(&self) -> &'static str {
match self {
RenderMode::Off => "off",
RenderMode::Minimal => "minimal",
RenderMode::Full => "full",
}
}
}
/// Hardcoded template for RenderMode::Off.
const TRIVIAL_TEMPLATE: &str = "{{ main }}";
/// Renders a `Function` into an executable shell script using a minijinja template.
///
/// `render_mode` controls the template source:
/// - `Off`: hardcoded `{{ main }}`, no bake_base file needed.
/// - `Minimal` / `Full`: reads bake_base.sh; the template distinguishes the two
/// via the `render_mode` context variable.
pub fn build_script(
function: &Function,
bake_base: &Path,
target_dir: &Path,
render_mode: RenderMode,
) -> Result<PathBuf, BakeError> {
let name = &function.name;
let body = &function.body;
let condition = function.find_decorator(|d| {
if let Decorator::If(content) = d {
Some(content.clone())
} else {
None
}
});
// let temp_path = temp_path.unwrap_or_else(|| PathBuf::from("/tmp"));
let temp_path = target_dir;
let filename = format!("bakefn_{}.sh", name);
let target = target_dir.join(filename);
let template_content = match render_mode {
RenderMode::Off => TRIVIAL_TEMPLATE.to_string(),
RenderMode::Minimal | RenderMode::Full => {
log::debug!("Reading bake_base: {:?}", bake_base);
std::fs::read_to_string(bake_base).map_err(|e| BakeError::IoError {
path: bake_base.display().to_string(),
source: e,
})?
}
};
let condition_code = match condition {
Some(content) => {
// with some bash workaround
format!(
"if [[ ! {} ]]; then\n exit {}\nfi\n",
content, PIPELINE_SKIP_ERRORCODE
)
}
None => String::new(),
};
// preexport/export: stubs for the future IPC-based @export.
let preexport_code = String::new();
let export_code = String::new();
let mut env = Environment::new();
env.add_template("script", &template_content)?;
let rendered = env.get_template("script")?.render(
context! {
main => body,
condition => condition_code,
preexport => preexport_code,
name => name,
export => export_code,
render_mode => render_mode.as_str(),
},
)?;
let mut temp_file =
NamedTempFile::new_in(&temp_path).map_err(|e| BakeError::ScriptGenerationFailed {
script: temp_path.display().to_string(),
action: "creating temp file".to_string(),
reason: e.to_string(),
})?;
temp_file
.as_file_mut()
.set_permissions(Permissions::from_mode(0o700))
.map_err(|e| BakeError::ScriptGenerationFailed {
script: temp_path.display().to_string(),
action: "setting permissions".to_string(),
reason: e.to_string(),
})?;
std::fs::write(&temp_file, rendered).map_err(|e| BakeError::ScriptGenerationFailed {
script: temp_path.display().to_string(),
action: "writing to temp file".to_string(),
reason: e.to_string(),
})?;
temp_file
.persist(&target)
.map_err(|e| BakeError::ScriptGenerationFailed {
script: target.display().to_string(),
action: "persisting temp file".to_string(),
reason: e.error.to_string(),
})?;
Ok(target)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_script_full_mode() {
let temp_dir = tempfile::tempdir().unwrap();
let bake_base = temp_dir.path().join("bake_base.sh");
std::fs::write(&bake_base, "{{ main }}\n").unwrap();
let function = Function {
name: "test".to_string(),
decorators: vec![],
body: "echo 'hello world'".to_string(),
};
let result = build_script(
&function, &bake_base, temp_dir.path(), RenderMode::Full,
);
assert!(result.is_ok());
let script_path = result.unwrap();
let content = std::fs::read_to_string(&script_path).unwrap();
assert!(content.contains("echo 'hello world'"));
}
#[test]
fn test_build_script_off_mode() {
let temp_dir = tempfile::tempdir().unwrap();
let function = Function {
name: "test".to_string(),
decorators: vec![],
body: "echo 'direct output'".to_string(),
};
let result = build_script(
&function,
Path::new("/nonexistent"),
temp_dir.path(),
RenderMode::Off,
);
assert!(result.is_ok());
let script_path = result.unwrap();
let content = std::fs::read_to_string(&script_path).unwrap();
assert_eq!(content.trim(), "echo 'direct output'");
}
}