Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/canonical-planner-fix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: canonical planner final fix

on:
push:
branches:
- feat/canonical-planner-354

permissions:
contents: write

jobs:
validate-and-persist:
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: feat/canonical-planner-354
fetch-depth: 0

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.94"
components: rustfmt,clippy

- name: Apply CLI regression fixes
run: python scripts/canonical_planner_cli_test_fix_tmp.py

- name: Format and validate exact repository test surface
run: |
cargo fmt --all
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-targets

- name: Persist validated tests
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add crates/renderflow-core/benches/cache.rs crates/renderflow-core/tests/canonical_planner.rs tests/common/mod.rs tests/cli_tests.rs tests/graph_integration_test.rs
git diff --cached --quiet && exit 0
git commit --message "test(planner): align integration coverage with canonical specs"
git push origin HEAD:feat/canonical-planner-354
1 change: 1 addition & 0 deletions crates/renderflow-core/src/adapters/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod command;
pub mod strategy;
233 changes: 233 additions & 0 deletions crates/renderflow-core/src/adapters/strategy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::artifact::{
Artifact, ArtifactDescriptor, ArtifactStorageClass, ArtifactStore, ArtifactTransform,
};
use crate::assets::normalize_asset_paths;
use crate::config::OutputType;
use crate::graph::Format;
use crate::input_format::InputFormat;
use crate::pipeline::Pipeline;
use crate::strategies::{select_strategy, RenderContext};

/// Artifact-native compatibility adapter for the mature document/image/audio
/// output strategies. The application planner registers this adapter as a graph
/// capability; callers never dispatch to a family-specific top-level pipeline.
pub struct StrategyArtifactTransform {
from: Format,
to: Format,
output_type: OutputType,
template: Option<String>,
profile: Option<String>,
variables: HashMap<String, String>,
source_asset_root: Option<PathBuf>,
cache_identity: String,
}

impl StrategyArtifactTransform {
pub fn new(
from: Format,
to: Format,
template: Option<String>,
profile: Option<String>,
variables: BTreeMap<String, String>,
source_asset_root: Option<PathBuf>,
) -> Result<Self> {
let output_type = output_type_for_format(to).ok_or_else(|| {
anyhow::anyhow!(
"format '{}' is not implemented by a built-in output strategy",
to
)
})?;
let variables: HashMap<String, String> = variables.into_iter().collect();
let mut identity_variables: Vec<_> = variables.iter().collect();
identity_variables.sort_by(|left, right| left.0.cmp(right.0));
let cache_identity = format!(
"renderflow.strategy-adapter/v1;from={from};to={to};template={template:?};profile={profile:?};source_root={:?};variables={identity_variables:?}",
source_asset_root
);
Ok(Self {
from,
to,
output_type,
template,
profile,
variables,
source_asset_root,
cache_identity,
})
}

fn prepare_document_input(
&self,
input: &Artifact,
store: &ArtifactStore,
work_dir: &Path,
) -> Result<PathBuf> {
let text = store.read_text(input).with_context(|| {
format!(
"built-in document adapter requires UTF-8 input for '{}'",
self.from
)
})?;
let normalized = if let Some(root) = &self.source_asset_root {
normalize_asset_paths(&text, root)?.into_owned()
} else {
text
};
let pipeline = Pipeline::with_standard_transforms(&self.variables, &self.output_type);
let transformed = pipeline
.run_transforms(normalized)
.context("built-in document transform phase failed")?;
let path = work_dir.join(format!("input.{}", self.from));
fs::write(&path, transformed).with_context(|| {
format!(
"failed to stage built-in document input '{}'",
path.display()
)
})?;
Ok(path)
}
}

impl ArtifactTransform for StrategyArtifactTransform {
fn name(&self) -> &str {
"renderflow.strategy-adapter"
}

fn cache_identity(&self) -> String {
self.cache_identity.clone()
}

fn apply(
&self,
input: &Artifact,
output_format: Format,
store: &ArtifactStore,
) -> Result<Artifact> {
if output_format != self.to {
anyhow::bail!(
"strategy adapter planned '{}' but executor requested '{}'",
self.to,
output_format
);
}

let work_dir = tempfile::tempdir_in(store.temporary_directory())
.context("failed to create strategy adapter work directory")?;
let document_input = document_input_format(self.from);
let input_path = if document_input.is_some() {
self.prepare_document_input(input, store, work_dir.path())?
} else {
store.payload_path(input)?
};
let output_path = work_dir.path().join(format!("output.{}", self.to));
let strategy = select_strategy(
&self.output_type,
self.template.as_deref(),
"templates",
self.profile.as_deref(),
)?;
let input_path_string = input_path
.to_str()
.context("strategy input path contains non-UTF8 characters")?;
let output_path_string = output_path
.to_str()
.context("strategy output path contains non-UTF8 characters")?;
let context = RenderContext {
input_path: input_path_string,
input_format: document_input.unwrap_or_default(),
output_path: output_path_string,
variables: &self.variables,
dry_run: false,
};
strategy.render(&context).with_context(|| {
format!(
"built-in strategy adapter failed for '{}' -> '{}'",
self.from, self.to
)
})?;
if !output_path.is_file() {
anyhow::bail!(
"built-in strategy '{}' -> '{}' completed without producing '{}'",
self.from,
self.to,
output_path.display()
);
}
store.import_path(
&output_path,
ArtifactDescriptor::for_format(output_format, ArtifactStorageClass::Intermediate)
.with_source(input.id().clone())
.with_metadata("renderflow.adapter", "builtin.strategy")
.with_metadata("renderflow.from", self.from.to_string())
.with_metadata("renderflow.to", self.to.to_string()),
)
}
}

pub fn document_input_format(format: Format) -> Option<InputFormat> {
match format {
Format::Markdown => Some(InputFormat::Markdown),
Format::Docx => Some(InputFormat::Docx),
Format::Html => Some(InputFormat::Html),
Format::Epub => Some(InputFormat::Epub),
Format::Rst => Some(InputFormat::Rst),
Format::Latex => Some(InputFormat::Latex),
_ => None,
}
}

pub fn output_type_for_format(format: Format) -> Option<OutputType> {
match format {
Format::Html => Some(OutputType::Html),
Format::Pdf => Some(OutputType::Pdf),
Format::Docx => Some(OutputType::Docx),
_ => {
let value = format.to_string();
if let Ok(audio) = value.parse::<crate::audio::AudioFormat>() {
if audio.supports_encoding() {
return Some(OutputType::Audio(audio));
}
}
if let Ok(image) = value.parse::<crate::image::ImageFormat>() {
if image.supports_encoding() {
return Some(OutputType::Image(image));
}
}
None
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn maps_builtin_document_and_media_outputs() {
assert_eq!(output_type_for_format(Format::Html), Some(OutputType::Html));
assert!(matches!(
output_type_for_format(Format::Png),
Some(OutputType::Image(_))
));
assert!(matches!(
output_type_for_format(Format::Flac),
Some(OutputType::Audio(_))
));
assert!(output_type_for_format(Format::Svg).is_none());
}

#[test]
fn document_input_mapping_is_explicit() {
assert_eq!(
document_input_format(Format::Markdown),
Some(InputFormat::Markdown)
);
assert_eq!(document_input_format(Format::Png), None);
}
}
8 changes: 1 addition & 7 deletions crates/renderflow-core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,7 @@ pub fn run_cli(cli: Cli) -> Result<()> {
target,
all,
}) => {
if let Some(ref target_format) = target {
commands::graph_build::run_target(&config, target_format, dry_run, optimization)?
} else if all {
commands::graph_build::run_all(&config, dry_run, optimization)?
} else {
commands::build::run(&config, dry_run, optimization)?
}
commands::build::run_selection(&config, dry_run, optimization, target.as_deref(), all)?
}
Some(Commands::Watch { config, debounce }) => commands::watch::run(&config, debounce)?,
Some(Commands::Audit) => commands::audit::run()?,
Expand Down
11 changes: 5 additions & 6 deletions crates/renderflow-core/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,14 @@ pub enum Commands {
#[arg(long, value_name = "MODE")]
optimization: Option<OptimizationMode>,

/// Build only the specified output format using graph-based path resolution.
/// The format must be reachable from the input format via the configured transforms.
/// Requires a 'transforms' key in the config file.
/// Cannot be combined with --all.
/// Build only the specified output format using the canonical capability graph.
/// Built-in document/image/audio capabilities and optional configured transforms are
/// resolved through the same planner. Cannot be combined with --all.
#[arg(long, value_name = "FORMAT", conflicts_with = "all")]
target: Option<String>,

/// Build all reachable output formats using graph-based path resolution.
/// Requires a 'transforms' key in the config file.
/// Build all policy-allowed output formats reachable through the canonical capability graph.
/// Built-in capabilities and optional configured transforms participate equally.
/// Cannot be combined with --target.
#[arg(long, conflicts_with = "target")]
all: bool,
Expand Down
Loading
Loading