Skip to content

Commit 6fde8da

Browse files
authored
Merge pull request #278 from egohygiene/copilot/add-collection-based-transforms
feat: add InputKind for collection-based (multi-input) transforms
2 parents 77916f4 + bf497af commit 6fde8da

5 files changed

Lines changed: 314 additions & 8 deletions

File tree

src/graph/definition.rs

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::{Format, TransformEdge};
1+
use super::{Format, InputKind, TransformEdge};
22

33
/// A pluggable definition of a format-to-format transformation.
44
///
@@ -12,15 +12,19 @@ use super::{Format, TransformEdge};
1212
/// `"pandoc"` or `"wkhtmltopdf"`), which helps with diagnostics and lets
1313
/// callers register multiple competing definitions for the same format pair.
1414
///
15+
/// The `input_kind` field controls whether the definition expects a single
16+
/// source document or a collection of source documents as input.
17+
///
1518
/// # Example
1619
///
1720
/// ```rust
18-
/// use renderflow::graph::{Format, TransformDefinition};
21+
/// use renderflow::graph::{Format, InputKind, TransformDefinition};
1922
///
2023
/// let def = TransformDefinition::new(Format::Markdown, Format::Html, 0.5, 1.0, "pandoc");
2124
/// assert_eq!(def.from, Format::Markdown);
2225
/// assert_eq!(def.to, Format::Html);
2326
/// assert_eq!(def.label, "pandoc");
27+
/// assert_eq!(def.input_kind, InputKind::Single);
2428
/// ```
2529
#[derive(Debug, Clone, PartialEq)]
2630
pub struct TransformDefinition {
@@ -34,10 +38,12 @@ pub struct TransformDefinition {
3438
pub quality: f32,
3539
/// Human-readable label identifying the tool or method (e.g. `"pandoc"`).
3640
pub label: String,
41+
/// Whether this definition consumes a single input or a collection.
42+
pub input_kind: InputKind,
3743
}
3844

3945
impl TransformDefinition {
40-
/// Create a new `TransformDefinition`.
46+
/// Create a new `TransformDefinition` with [`InputKind::Single`].
4147
///
4248
/// # Parameters
4349
///
@@ -54,13 +60,44 @@ impl TransformDefinition {
5460
cost,
5561
quality: quality.clamp(0.0, 1.0),
5662
label: label.into(),
63+
input_kind: InputKind::Single,
64+
}
65+
}
66+
67+
/// Create a new `TransformDefinition` with an explicit [`InputKind`].
68+
///
69+
/// Use this when registering a collection-based transform (e.g. pages → book).
70+
///
71+
/// # Parameters
72+
///
73+
/// * `from` – source [`Format`]
74+
/// * `to` – target [`Format`]
75+
/// * `cost` – relative execution cost (lower is cheaper)
76+
/// * `quality` – expected output quality in the range `[0.0, 1.0]`
77+
/// * `label` – human-readable name identifying the conversion tool or method
78+
/// * `input_kind` – whether this definition consumes a single input or a collection
79+
pub fn with_input_kind(
80+
from: Format,
81+
to: Format,
82+
cost: f32,
83+
quality: f32,
84+
label: impl Into<String>,
85+
input_kind: InputKind,
86+
) -> Self {
87+
Self {
88+
from,
89+
to,
90+
cost,
91+
quality: quality.clamp(0.0, 1.0),
92+
label: label.into(),
93+
input_kind,
5794
}
5895
}
5996

6097
/// Convert this definition into a [`TransformEdge`] for use in a
6198
/// [`TransformGraph`](super::TransformGraph).
6299
pub fn to_edge(&self) -> TransformEdge {
63-
TransformEdge::new(self.from, self.to, self.cost, self.quality)
100+
TransformEdge::with_input_kind(self.from, self.to, self.cost, self.quality, self.input_kind)
64101
}
65102
}
66103

@@ -78,6 +115,7 @@ mod tests {
78115
assert_eq!(def.cost, 0.5);
79116
assert!((def.quality - 1.0).abs() < 1e-5);
80117
assert_eq!(def.label, "pandoc");
118+
assert_eq!(def.input_kind, InputKind::Single);
81119
}
82120

83121
#[test]
@@ -134,6 +172,32 @@ mod tests {
134172
assert_ne!(a, b);
135173
}
136174

175+
// ── with_input_kind ───────────────────────────────────────────────────────
176+
177+
#[test]
178+
fn test_with_input_kind_collection() {
179+
let def = TransformDefinition::with_input_kind(
180+
Format::Markdown, Format::Epub, 1.0, 0.85, "book-assembler", InputKind::Collection,
181+
);
182+
assert_eq!(def.input_kind, InputKind::Collection);
183+
assert_eq!(def.label, "book-assembler");
184+
}
185+
186+
#[test]
187+
fn test_new_defaults_to_single_input_kind() {
188+
let def = TransformDefinition::new(Format::Markdown, Format::Html, 0.5, 1.0, "pandoc");
189+
assert_eq!(def.input_kind, InputKind::Single);
190+
}
191+
192+
#[test]
193+
fn test_definition_inequality_by_input_kind() {
194+
let a = TransformDefinition::new(Format::Markdown, Format::Epub, 1.0, 0.85, "tool");
195+
let b = TransformDefinition::with_input_kind(
196+
Format::Markdown, Format::Epub, 1.0, 0.85, "tool", InputKind::Collection,
197+
);
198+
assert_ne!(a, b);
199+
}
200+
137201
// ── to_edge ───────────────────────────────────────────────────────────────
138202

139203
#[test]
@@ -153,4 +217,20 @@ mod tests {
153217
let edge = def.to_edge();
154218
assert!((edge.quality - 1.0).abs() < 1e-5);
155219
}
220+
221+
#[test]
222+
fn test_to_edge_propagates_input_kind_single() {
223+
let def = TransformDefinition::new(Format::Markdown, Format::Html, 0.5, 1.0, "pandoc");
224+
let edge = def.to_edge();
225+
assert_eq!(edge.input_kind, InputKind::Single);
226+
}
227+
228+
#[test]
229+
fn test_to_edge_propagates_input_kind_collection() {
230+
let def = TransformDefinition::with_input_kind(
231+
Format::Markdown, Format::Epub, 1.0, 0.85, "book-assembler", InputKind::Collection,
232+
);
233+
let edge = def.to_edge();
234+
assert_eq!(edge.input_kind, InputKind::Collection);
235+
}
156236
}

src/graph/input_kind.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/// Describes how many inputs a transformation consumes.
2+
///
3+
/// Most transformations operate on a **single** source document and produce a
4+
/// single output document. Some transformations – such as assembling a book
5+
/// from a collection of pages – consume **multiple** inputs simultaneously and
6+
/// produce a single aggregated output.
7+
///
8+
/// # Example
9+
///
10+
/// ```rust
11+
/// use renderflow::graph::InputKind;
12+
///
13+
/// // The default for a standard edge is a single input.
14+
/// assert_eq!(InputKind::default(), InputKind::Single);
15+
///
16+
/// // A collection-based edge aggregates multiple inputs.
17+
/// let kind = InputKind::Collection;
18+
/// assert!(kind.is_collection());
19+
/// ```
20+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
21+
pub enum InputKind {
22+
/// The transformation operates on a single source document.
23+
#[default]
24+
Single,
25+
/// The transformation consumes a collection of source documents and
26+
/// produces one aggregated output (e.g. pages → book).
27+
Collection,
28+
}
29+
30+
impl InputKind {
31+
/// Return `true` when this is the [`Single`](InputKind::Single) variant.
32+
pub fn is_single(self) -> bool {
33+
self == InputKind::Single
34+
}
35+
36+
/// Return `true` when this is the [`Collection`](InputKind::Collection) variant.
37+
pub fn is_collection(self) -> bool {
38+
self == InputKind::Collection
39+
}
40+
}
41+
42+
impl std::fmt::Display for InputKind {
43+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44+
match self {
45+
InputKind::Single => write!(f, "single"),
46+
InputKind::Collection => write!(f, "collection"),
47+
}
48+
}
49+
}
50+
51+
#[cfg(test)]
52+
mod tests {
53+
use super::*;
54+
55+
#[test]
56+
fn test_default_is_single() {
57+
assert_eq!(InputKind::default(), InputKind::Single);
58+
}
59+
60+
#[test]
61+
fn test_single_is_single() {
62+
assert!(InputKind::Single.is_single());
63+
assert!(!InputKind::Single.is_collection());
64+
}
65+
66+
#[test]
67+
fn test_collection_is_collection() {
68+
assert!(InputKind::Collection.is_collection());
69+
assert!(!InputKind::Collection.is_single());
70+
}
71+
72+
#[test]
73+
fn test_display_single() {
74+
assert_eq!(InputKind::Single.to_string(), "single");
75+
}
76+
77+
#[test]
78+
fn test_display_collection() {
79+
assert_eq!(InputKind::Collection.to_string(), "collection");
80+
}
81+
82+
#[test]
83+
fn test_clone_copy() {
84+
let kind = InputKind::Collection;
85+
let copied = kind;
86+
assert_eq!(kind, copied);
87+
}
88+
89+
#[test]
90+
fn test_equality() {
91+
assert_eq!(InputKind::Single, InputKind::Single);
92+
assert_eq!(InputKind::Collection, InputKind::Collection);
93+
assert_ne!(InputKind::Single, InputKind::Collection);
94+
}
95+
}

src/graph/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
mod definition;
22
mod definition_registry;
33
mod format;
4+
mod input_kind;
45
mod multi_target;
56
mod pathfinding;
67
mod transform_edge;
78

89
pub use definition::TransformDefinition;
910
pub use definition_registry::TransformDefinitionRegistry;
1011
pub use format::Format;
12+
pub use input_kind::InputKind;
1113
pub use multi_target::MultiTargetDag;
1214
pub use pathfinding::TransformPath;
1315
pub use transform_edge::TransformEdge;
@@ -73,6 +75,17 @@ impl TransformGraph {
7375
self.graph.add_edge(from_idx, to_idx, edge);
7476
}
7577

78+
/// Add a collection-based transformation to the graph.
79+
///
80+
/// Equivalent to calling [`add_transform`](Self::add_transform) with a
81+
/// [`TransformEdge`] whose [`input_kind`](TransformEdge::input_kind) is
82+
/// [`InputKind::Collection`]. Use this for aggregation-style transforms
83+
/// where multiple source documents are combined into a single output
84+
/// (e.g. pages → book).
85+
pub fn add_collection_transform(&mut self, from: Format, to: Format, cost: f32, quality: f32) {
86+
self.add_transform(TransformEdge::with_input_kind(from, to, cost, quality, InputKind::Collection));
87+
}
88+
7689
/// Return all [`TransformEdge`]s whose source is `from`.
7790
///
7891
/// Returns an empty `Vec` when no outgoing edges exist for the given format.

src/graph/multi_target.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,23 @@ impl MultiTargetDag {
132132
pub fn node_count(&self) -> usize {
133133
self.graph.node_count()
134134
}
135+
136+
/// Return all edges in the DAG whose [`InputKind`] is [`Collection`](InputKind::Collection).
137+
///
138+
/// Collection edges represent aggregation-style transformations that consume
139+
/// multiple source documents simultaneously (e.g. pages → book).
140+
pub fn collection_edges(&self) -> Vec<&TransformEdge> {
141+
self.graph
142+
.edge_weights()
143+
.filter(|e| e.input_kind.is_collection())
144+
.collect()
145+
}
135146
}
136147

137148
#[cfg(test)]
138149
mod tests {
139150
use super::*;
140-
use crate::graph::TransformGraph;
151+
use crate::graph::{InputKind, TransformGraph};
141152

142153
fn build_graph() -> TransformGraph {
143154
let mut g = TransformGraph::new();
@@ -324,4 +335,57 @@ mod tests {
324335

325336
assert_eq!(dag.all_edges().len(), dag.edge_count());
326337
}
338+
339+
// ── collection_edges ──────────────────────────────────────────────────────
340+
341+
#[test]
342+
fn test_collection_edges_empty_when_no_collection_edges() {
343+
let g = build_graph();
344+
let dag = g
345+
.build_multi_target_dag(Format::Markdown, &[Format::Pdf, Format::Docx])
346+
.unwrap();
347+
348+
assert!(dag.collection_edges().is_empty());
349+
}
350+
351+
#[test]
352+
fn test_collection_edges_returns_only_collection_edges() {
353+
let mut dag = MultiTargetDag::new();
354+
dag.merge_edge(TransformEdge::new(Format::Markdown, Format::Html, 0.5, 1.0));
355+
dag.merge_edge(TransformEdge::with_input_kind(
356+
Format::Markdown, Format::Epub, 1.0, 0.85, InputKind::Collection,
357+
));
358+
359+
let collection = dag.collection_edges();
360+
assert_eq!(collection.len(), 1);
361+
assert_eq!(collection[0].to, Format::Epub);
362+
assert_eq!(collection[0].input_kind, InputKind::Collection);
363+
}
364+
365+
#[test]
366+
fn test_collection_edges_does_not_include_single_edges() {
367+
let mut dag = MultiTargetDag::new();
368+
dag.merge_edge(TransformEdge::new(Format::Markdown, Format::Html, 0.5, 1.0));
369+
dag.merge_edge(TransformEdge::with_input_kind(
370+
Format::Markdown, Format::Epub, 1.0, 0.85, InputKind::Collection,
371+
));
372+
373+
let collection = dag.collection_edges();
374+
assert!(collection.iter().all(|e| e.input_kind.is_collection()));
375+
}
376+
377+
#[test]
378+
fn test_collection_edges_multiple() {
379+
let mut dag = MultiTargetDag::new();
380+
dag.merge_edge(TransformEdge::with_input_kind(
381+
Format::Markdown, Format::Epub, 1.0, 0.85, InputKind::Collection,
382+
));
383+
dag.merge_edge(TransformEdge::with_input_kind(
384+
Format::Html, Format::Pdf, 0.8, 0.85, InputKind::Collection,
385+
));
386+
dag.merge_edge(TransformEdge::new(Format::Markdown, Format::Html, 0.5, 1.0));
387+
388+
let collection = dag.collection_edges();
389+
assert_eq!(collection.len(), 2);
390+
}
327391
}

0 commit comments

Comments
 (0)