Skip to main content

apimock_routing/view/
build.rs

1//! Builders that turn the in-memory routing model into the view types
2//! a GUI consumes.
3//!
4//! # Why these aren't `From` impls
5//!
6//! The view shapes need contextual information the source types don't
7//! carry — the `index` field on `RuleSetView` and `RuleView` for
8//! example. Free functions taking the index alongside the model keep
9//! the call sites explicit and type-checked. A `From<&RuleSet>` impl
10//! would have to invent the index (probably defaulting to zero) which
11//! is just the kind of silent-bug surface we want to avoid.
12//!
13//! # Why this is a sibling module rather than baked into `view.rs`
14//!
15//! `view.rs` is the *type* surface — it must stay stable across
16//! routing-crate refactors so a GUI's bindings don't churn. Builders
17//! depend on the internal `RuleSet` / `Rule` / `When` shapes which
18//! *do* churn. Keeping them in their own module makes the dependency
19//! direction obvious: `view::build` may import from anywhere in the
20//! crate; `view.rs` itself stays leaf.
21
22use std::path::Path;
23
24use serde_json;
25
26use crate::rule_set::rule::respond::Respond;
27use crate::rule_set::rule::when::When;
28use crate::rule_set::rule::when::request::Request;
29use crate::rule_set::rule::when::request::http_method::HttpMethod;
30use crate::rule_set::rule::when::request::rule_op::RuleOp;
31use crate::rule_set::rule::when::request::url_path::UrlPathConfig;
32use crate::rule_set::rule::Rule;
33use crate::rule_set::RuleSet;
34
35use crate::view::{
36    BodyConditionView, FileNodeKind, FileNodeView, FileTreeView, HeaderConditionView,
37    RespondView, RouteCatalogSnapshot, RuleSetView, RuleView, ScriptRouteView, UrlPathView,
38    WhenView,
39};
40
41/// Compose the top-level `RouteCatalogSnapshot` from already-built
42/// components. Caller supplies `file_tree` and `script_routes` because
43/// the routing crate doesn't know about middleware-file paths or the
44/// fallback dir's location — those live in `apimock-config`.
45pub fn build_route_catalog(
46    rule_sets: &[RuleSet],
47    fallback_respond_dir: Option<&str>,
48    file_tree: Option<FileTreeView>,
49    script_routes: Vec<ScriptRouteView>,
50) -> RouteCatalogSnapshot {
51    let rule_set_views = rule_sets
52        .iter()
53        .enumerate()
54        .map(|(idx, rs)| build_rule_set_view(rs, idx))
55        .collect();
56
57    RouteCatalogSnapshot {
58        rule_sets: rule_set_views,
59        fallback_respond_dir: fallback_respond_dir.map(str::to_owned),
60        file_tree,
61        script_routes,
62    }
63}
64
65pub fn build_rule_set_view(rule_set: &RuleSet, index: usize) -> RuleSetView {
66    let (url_prefix, dir_prefix) = match rule_set.prefix.as_ref() {
67        Some(p) => (p.url_path_prefix.clone(), p.respond_dir_prefix.clone()),
68        None => (None, None),
69    };
70
71    RuleSetView {
72        index,
73        source_path: rule_set.file_path.clone(),
74        url_path_prefix: url_prefix,
75        respond_dir_prefix: dir_prefix,
76        rules: rule_set
77            .rules
78            .iter()
79            .enumerate()
80            .map(|(idx, r)| build_rule_view(r, idx))
81            .collect(),
82    }
83}
84
85pub fn build_rule_view(rule: &Rule, index: usize) -> RuleView {
86    RuleView {
87        index,
88        when: build_when_view(&rule.when),
89        respond: build_respond_view(&rule.respond),
90    }
91}
92
93pub fn build_when_view(when: &When) -> WhenView {
94    let req: &Request = &when.request;
95    WhenView {
96        url_path: build_url_path_view(req.url_path_config.as_ref()),
97        method: req.http_method.as_ref().map(http_method_name),
98        headers: build_header_condition_views(req.headers.as_ref()),
99        body: build_body_condition_views(req.body.as_ref()),
100    }
101}
102
103fn build_header_condition_views(
104    headers: Option<&crate::rule_set::rule::when::request::headers::Headers>,
105) -> Vec<HeaderConditionView> {
106    let headers = match headers {
107        Some(h) => h,
108        None => return Vec::new(),
109    };
110    // IndexMap preserves insertion (TOML authoring) order — no sort needed.
111    headers
112        .0
113        .iter()
114        .map(|(name, stmt)| {
115            let op_str = op_name(stmt.op.as_ref().unwrap_or(&RuleOp::default()));
116            HeaderConditionView {
117                name: name.clone(),
118                op: op_str,
119                value: Some(stmt.value.clone()),
120            }
121        })
122        .collect()
123}
124
125fn build_body_condition_views(
126    body: Option<&crate::rule_set::rule::when::request::body::Body>,
127) -> Vec<BodyConditionView> {
128    use crate::rule_set::rule::when::request::body::body_kind::BodyKind;
129    use crate::rule_set::rule::when::request::body::body_operator::BodyOperator;
130
131    let body = match body {
132        Some(b) => b,
133        None => return Vec::new(),
134    };
135
136    let mut views: Vec<BodyConditionView> = Vec::new();
137    for (kind, conditions) in &body.0 {
138        let kind_str = match kind {
139            BodyKind::Json => "json",
140        };
141        for (path, stmt) in conditions {
142            let op_str = format!(
143                "{}",
144                stmt.op.as_ref().unwrap_or(&BodyOperator::Equal)
145            )
146            .trim()
147            .to_owned();
148            // Normalise the op display string to snake_case form matching
149            // the serde rename: strip surrounding spaces, lower-case.
150            let op_clean = body_op_name(stmt.op.as_ref().unwrap_or(&BodyOperator::Equal));
151            // value: try to parse as JSON; fall back to JSON string.
152            let value = serde_json::from_str::<serde_json::Value>(&stmt.value)
153                .unwrap_or_else(|_| serde_json::Value::String(stmt.value.clone()));
154            let _ = op_str; // suppress unused warning
155            views.push(BodyConditionView {
156                kind: kind_str.to_owned(),
157                path: path.clone(),
158                op: op_clean,
159                value,
160            });
161        }
162    }
163    // Stable order: alphabetical by path.
164    views.sort_by(|a, b| a.path.cmp(&b.path));
165    views
166}
167
168/// Public wrapper so `toml_writer` can serialise body operators to
169/// TOML `op` strings without importing routing-internal types.
170pub fn body_op_name_pub(op: &crate::rule_set::rule::when::request::body::body_operator::BodyOperator) -> String {
171    body_op_name(op)
172}
173
174fn body_op_name(op: &crate::rule_set::rule::when::request::body::body_operator::BodyOperator) -> String {
175    use crate::rule_set::rule::when::request::body::body_operator::BodyOperator;
176    match op {
177        BodyOperator::Equal => "equal",
178        BodyOperator::EqualString => "equal_string",
179        BodyOperator::Contains => "contains",
180        BodyOperator::StartsWith => "starts_with",
181        BodyOperator::EndsWith => "ends_with",
182        BodyOperator::Regex => "regex",
183        BodyOperator::EqualTyped => "equal_typed",
184        BodyOperator::EqualNumber => "equal_number",
185        BodyOperator::GreaterThan => "greater_than",
186        BodyOperator::LessThan => "less_than",
187        BodyOperator::GreaterOrEqual => "greater_or_equal",
188        BodyOperator::LessOrEqual => "less_or_equal",
189        BodyOperator::Exists => "exists",
190        BodyOperator::Absent => "absent",
191        BodyOperator::ArrayLengthEqual => "array_length_equal",
192        BodyOperator::ArrayLengthAtLeast => "array_length_at_least",
193        BodyOperator::ArrayContains => "array_contains",
194        BodyOperator::EqualInteger => "equal_integer",
195    }
196    .to_owned()
197}
198
199fn build_url_path_view(cfg: Option<&UrlPathConfig>) -> Option<UrlPathView> {
200    let cfg = cfg?;
201    let (value, op) = match cfg {
202        UrlPathConfig::Simple(s) => (s.clone(), op_name(&RuleOp::default())),
203        UrlPathConfig::Detailed(detail) => {
204            let op = detail
205                .op
206                .as_ref()
207                .map(op_name)
208                .unwrap_or_else(|| op_name(&RuleOp::default()));
209            (detail.value.clone(), op)
210        }
211    };
212    Some(UrlPathView { value, op })
213}
214
215/// TOML-form name for a `RuleOp`. The `Display` impl on `RuleOp`
216/// produces a human-readable form (`" == "`, `" starts with "`),
217/// which is good for log output but not for a stable identifier the
218/// GUI can match against. We translate to the same `snake_case` form
219/// `serde(rename_all = "snake_case")` produces on the way in, so the
220/// view round-trips back to the original TOML keyword.
221pub fn op_name(op: &RuleOp) -> String {
222    match op {
223        RuleOp::Equal => "equal",
224        RuleOp::NotEqual => "not_equal",
225        RuleOp::StartsWith => "starts_with",
226        RuleOp::Contains => "contains",
227        RuleOp::WildCard => "wild_card",
228    }
229    .to_owned()
230}
231
232fn http_method_name(m: &HttpMethod) -> String {
233    m.as_str().to_owned()
234}
235
236pub fn build_respond_view(respond: &Respond) -> RespondView {
237    if let Some(path) = respond.file_path.as_ref() {
238        return RespondView::File {
239            path: path.clone(),
240            csv_records_key: respond.csv_records_key.clone(),
241        };
242    }
243    if let Some(text) = respond.text.as_ref() {
244        return RespondView::Text {
245            text: text.clone(),
246            status: respond.status,
247        };
248    }
249    if let Some(status) = respond.status {
250        return RespondView::Status { code: status };
251    }
252    // Fallback for an empty respond — not legal per validation, but
253    // surface it as an empty text body so the snapshot stays
254    // well-formed for GUIs that re-render mid-edit.
255    RespondView::Text {
256        text: String::new(),
257        status: None,
258    }
259}
260
261// -------------------------------------------------------------------
262// File tree (depth-1 eager) — RFC 005 filtering
263// -------------------------------------------------------------------
264
265/// Built-in directory names to exclude from `FileTreeView` by default.
266/// These are overwhelmingly build outputs / VCS metadata across common
267/// ecosystems; projects with unusual layouts can disable via
268/// `FileTreeFilter::builtin_excludes = false`.
269pub const BUILTIN_EXCLUDES: &[&str] = &[
270    "target",
271    "node_modules",
272    "dist",
273    "build",
274    "out",
275    "__pycache__",
276    ".venv",
277    "vendor",
278    ".cargo",
279    ".gradle",
280    ".idea",
281    ".vscode",
282];
283
284/// Filter options controlling which entries appear in [`FileTreeView`].
285///
286/// # Defaults
287///
288/// - `show_hidden = false` — hide dotfiles / dot-directories.
289/// - `builtin_excludes = true` — hide known build-output directories.
290/// - `extra_excludes = []` — no additional exclusions.
291/// - `include = []` — include everything (no inclusion filter).
292///
293/// The defaults are intentionally conservative: they hide the noise
294/// without requiring any configuration for the common case.
295#[derive(Clone, Debug)]
296pub struct FileTreeFilter {
297    /// When `false`, entries whose name starts with `.` are excluded.
298    pub show_hidden: bool,
299    /// When `true`, entries whose name appears in [`BUILTIN_EXCLUDES`]
300    /// are excluded.
301    pub builtin_excludes: bool,
302    /// Additional entry names to exclude (exact-match on the entry's
303    /// `file_name()`, not a glob).
304    pub extra_excludes: Vec<String>,
305    /// If non-empty, only files whose name matches at least one entry
306    /// (simple suffix match) are included. Directories are always kept
307    /// so the user can drill into them.
308    pub include: Vec<String>,
309}
310
311impl Default for FileTreeFilter {
312    fn default() -> Self {
313        Self {
314            show_hidden: false,
315            builtin_excludes: true,
316            extra_excludes: Vec::new(),
317            include: Vec::new(),
318        }
319    }
320}
321
322impl FileTreeFilter {
323    /// Return `true` iff `name` should be kept (not filtered out).
324    fn keep(&self, name: &str, is_dir: bool) -> bool {
325        // Dotfile filter
326        if !self.show_hidden && name.starts_with('.') {
327            return false;
328        }
329        // Built-in exclude list
330        if self.builtin_excludes && BUILTIN_EXCLUDES.contains(&name) {
331            return false;
332        }
333        // Extra excludes
334        if self.extra_excludes.iter().any(|e| e == name) {
335            return false;
336        }
337        // Include filter applies only to files; directories always pass.
338        if !is_dir && !self.include.is_empty() {
339            if !self.include.iter().any(|pat| name.ends_with(pat.as_str())) {
340                return false;
341            }
342        }
343        true
344    }
345}
346
347/// Build a depth-1 file-tree view rooted at `root`.
348///
349/// Applies [`FileTreeFilter::default()`] to exclude hidden entries and
350/// known build-output directories. Use [`build_file_tree_with`] to
351/// supply custom filter options.
352pub fn build_file_tree(root: &Path) -> Option<FileTreeView> {
353    build_file_tree_with(root, &FileTreeFilter::default())
354}
355
356/// Build a depth-1 file-tree view with an explicit [`FileTreeFilter`].
357///
358/// Returns `None` if the directory doesn't exist or can't be read.
359/// Subdirectories carry `children = Some(Vec::new())` to flag them as
360/// expandable-but-not-yet-expanded.
361pub fn build_file_tree_with(root: &Path, filter: &FileTreeFilter) -> Option<FileTreeView> {
362    let entries = std::fs::read_dir(root).ok()?;
363    let mut nodes: Vec<FileNodeView> = Vec::new();
364
365    for entry in entries.flatten() {
366        let path = entry.path();
367        let name = path
368            .file_name()
369            .map(|n| n.to_string_lossy().into_owned())
370            .unwrap_or_default();
371        let metadata = match entry.metadata() {
372            Ok(m) => m,
373            Err(_) => continue,
374        };
375        let is_dir = metadata.is_dir();
376        let kind = if is_dir {
377            FileNodeKind::Directory
378        } else {
379            FileNodeKind::File
380        };
381
382        // Apply filter
383        if !filter.keep(&name, is_dir) {
384            continue;
385        }
386
387        let route_hint = if matches!(kind, FileNodeKind::File) {
388            path.file_stem()
389                .map(|s| format!("/{}", s.to_string_lossy()))
390        } else {
391            None
392        };
393
394        let children = match kind {
395            FileNodeKind::Directory => Some(Vec::new()),
396            FileNodeKind::File => None,
397        };
398
399        nodes.push(FileNodeView {
400            name,
401            path: path.to_string_lossy().into_owned(),
402            kind,
403            route_hint,
404            children,
405        });
406    }
407
408    // Stable rendering: directories first, then files; alphabetical within each group.
409    nodes.sort_by(|a, b| match (&a.kind, &b.kind) {
410        (FileNodeKind::Directory, FileNodeKind::File) => std::cmp::Ordering::Less,
411        (FileNodeKind::File, FileNodeKind::Directory) => std::cmp::Ordering::Greater,
412        _ => a.name.cmp(&b.name),
413    });
414
415    Some(FileTreeView {
416        root_path: root.to_string_lossy().into_owned(),
417        entries: nodes,
418    })
419}
420
421/// Same shape as `build_file_tree`, but for ad-hoc subdirectory
422/// expansion. Applies [`FileTreeFilter::default()`].
423pub fn list_directory(path: &Path) -> Vec<FileNodeView> {
424    list_directory_with(path, &FileTreeFilter::default())
425}
426
427/// Subdirectory expansion with an explicit filter.
428pub fn list_directory_with(path: &Path, filter: &FileTreeFilter) -> Vec<FileNodeView> {
429    build_file_tree_with(path, filter)
430        .map(|t| t.entries)
431        .unwrap_or_default()
432}
433
434// -------------------------------------------------------------------
435// Script routes
436// -------------------------------------------------------------------
437
438/// Build a `ScriptRouteView` from a middleware path and its index in
439/// `service.middlewares_file_paths`.
440pub fn build_script_route_view(index: usize, source_file: &str) -> ScriptRouteView {
441    let display_name = Path::new(source_file)
442        .file_name()
443        .map(|n| n.to_string_lossy().into_owned())
444        .unwrap_or_else(|| source_file.to_owned());
445
446    ScriptRouteView {
447        index,
448        source_file: source_file.to_owned(),
449        display_name,
450    }
451}