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::RuleSet;
27use crate::rule_set::rule::Rule;
28use crate::rule_set::rule::respond::Respond;
29use crate::rule_set::rule::when::When;
30use crate::rule_set::rule::when::request::Request;
31use crate::rule_set::rule::when::request::http_method::HttpMethod;
32use crate::rule_set::rule::when::request::rule_op::RuleOp;
33use crate::rule_set::rule::when::request::url_path::UrlPathConfig;
34
35use crate::view::{
36    BodyConditionView, FileNodeKind, FileNodeView, FileTreeView, HeaderConditionView, RespondView,
37    RouteCatalogSnapshot, RuleSetView, RuleView, ScriptRouteView, UrlPathView, WhenView,
38};
39
40/// Compose the top-level `RouteCatalogSnapshot` from already-built
41/// components. Caller supplies `file_tree` and `script_routes` because
42/// the routing crate doesn't know about middleware-file paths or the
43/// fallback dir's location — those live in `apimock-config`.
44pub fn build_route_catalog(
45    rule_sets: &[RuleSet],
46    fallback_respond_dir: Option<&str>,
47    file_tree: Option<FileTreeView>,
48    script_routes: Vec<ScriptRouteView>,
49) -> RouteCatalogSnapshot {
50    let rule_set_views = rule_sets
51        .iter()
52        .enumerate()
53        .map(|(idx, rs)| build_rule_set_view(rs, idx))
54        .collect();
55
56    RouteCatalogSnapshot {
57        rule_sets: rule_set_views,
58        fallback_respond_dir: fallback_respond_dir.map(str::to_owned),
59        file_tree,
60        script_routes,
61    }
62}
63
64pub fn build_rule_set_view(rule_set: &RuleSet, index: usize) -> RuleSetView {
65    let (url_prefix, dir_prefix) = match rule_set.prefix.as_ref() {
66        Some(p) => (p.url_path_prefix.clone(), p.respond_dir_prefix.clone()),
67        None => (None, None),
68    };
69
70    RuleSetView {
71        index,
72        source_path: rule_set.file_path.clone(),
73        url_path_prefix: url_prefix,
74        respond_dir_prefix: dir_prefix,
75        strategy: rule_set.strategy.as_ref().map(|s| s.to_string()),
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        priority: rule.priority,
89        when: build_when_view(&rule.when),
90        respond: build_respond_view(&rule.respond),
91    }
92}
93
94pub fn build_when_view(when: &When) -> WhenView {
95    let req: &Request = &when.request;
96    WhenView {
97        url_path: build_url_path_view(req.url_path_config.as_ref()),
98        method: req.http_method.as_ref().map(http_method_name),
99        headers: build_header_condition_views(req.headers.as_ref()),
100        body: build_body_condition_views(req.body.as_ref()),
101    }
102}
103
104fn build_header_condition_views(
105    headers: Option<&crate::rule_set::rule::when::request::headers::Headers>,
106) -> Vec<HeaderConditionView> {
107    use crate::rule_set::rule::when::request::headers::header_operator::HeaderOperator;
108
109    let headers = match headers {
110        Some(h) => h,
111        None => return Vec::new(),
112    };
113    // IndexMap preserves insertion (TOML authoring) order — no sort needed.
114    headers
115        .0
116        .iter()
117        .map(|(name, stmt)| {
118            let op = stmt.op.clone().unwrap_or_default();
119            let op_str = op.as_str().to_owned();
120            // Presence operators have no meaningful value to display.
121            let value = match op {
122                HeaderOperator::Exists | HeaderOperator::Absent => None,
123                _ => Some(stmt.value.clone()),
124            };
125            HeaderConditionView {
126                name: name.clone(),
127                op: op_str,
128                value,
129            }
130        })
131        .collect()
132}
133
134fn build_body_condition_views(
135    body: Option<&crate::rule_set::rule::when::request::body::Body>,
136) -> Vec<BodyConditionView> {
137    use crate::rule_set::rule::when::request::body::body_kind::BodyKind;
138    use crate::rule_set::rule::when::request::body::body_operator::BodyOperator;
139
140    let body = match body {
141        Some(b) => b,
142        None => return Vec::new(),
143    };
144
145    let mut views: Vec<BodyConditionView> = Vec::new();
146    for (kind, conditions) in &body.0 {
147        let kind_str = match kind {
148            BodyKind::Json => "json",
149        };
150        for (path, stmt) in conditions {
151            let op_str = format!("{}", stmt.op.as_ref().unwrap_or(&BodyOperator::Equal))
152                .trim()
153                .to_owned();
154            // Normalise the op display string to snake_case form matching
155            // the serde rename: strip surrounding spaces, lower-case.
156            let op_clean = body_op_name(stmt.op.as_ref().unwrap_or(&BodyOperator::Equal));
157            // value: try to parse as JSON; fall back to JSON string.
158            let value = serde_json::from_str::<serde_json::Value>(&stmt.value)
159                .unwrap_or_else(|_| serde_json::Value::String(stmt.value.clone()));
160            let _ = op_str; // suppress unused warning
161            views.push(BodyConditionView {
162                kind: kind_str.to_owned(),
163                path: path.clone(),
164                op: op_clean,
165                value,
166            });
167        }
168    }
169    // Stable order: alphabetical by path.
170    views.sort_by(|a, b| a.path.cmp(&b.path));
171    views
172}
173
174/// Public wrapper so `toml_writer` can serialise body operators to
175/// TOML `op` strings without importing routing-internal types.
176pub fn body_op_name_pub(
177    op: &crate::rule_set::rule::when::request::body::body_operator::BodyOperator,
178) -> String {
179    body_op_name(op)
180}
181
182fn body_op_name(
183    op: &crate::rule_set::rule::when::request::body::body_operator::BodyOperator,
184) -> String {
185    use crate::rule_set::rule::when::request::body::body_operator::BodyOperator;
186    match op {
187        BodyOperator::Equal => "equal",
188        BodyOperator::EqualString => "equal_string",
189        BodyOperator::Contains => "contains",
190        BodyOperator::NotContains => "not_contains",
191        BodyOperator::StartsWith => "starts_with",
192        BodyOperator::NotStartsWith => "not_starts_with",
193        BodyOperator::EndsWith => "ends_with",
194        BodyOperator::NotEndsWith => "not_ends_with",
195        BodyOperator::Regex => "regex",
196        BodyOperator::NotRegex => "not_regex",
197        BodyOperator::EqualTyped => "equal_typed",
198        BodyOperator::EqualNumber => "equal_number",
199        BodyOperator::GreaterThan => "greater_than",
200        BodyOperator::LessThan => "less_than",
201        BodyOperator::GreaterOrEqual => "greater_or_equal",
202        BodyOperator::LessOrEqual => "less_or_equal",
203        BodyOperator::Exists => "exists",
204        BodyOperator::Absent => "absent",
205        BodyOperator::ArrayLengthEqual => "array_length_equal",
206        BodyOperator::ArrayLengthAtLeast => "array_length_at_least",
207        BodyOperator::ArrayContains => "array_contains",
208        BodyOperator::EqualInteger => "equal_integer",
209        BodyOperator::MapHasKey => "map_has_key",
210        BodyOperator::MapDoesNotHaveKey => "map_does_not_have_key",
211        BodyOperator::StructuralContains => "structural_contains",
212    }
213    .to_owned()
214}
215
216fn build_url_path_view(cfg: Option<&UrlPathConfig>) -> Option<UrlPathView> {
217    let cfg = cfg?;
218    let (value, op) = match cfg {
219        UrlPathConfig::Simple(s) => (s.clone(), op_name(&RuleOp::default())),
220        UrlPathConfig::Detailed(detail) => {
221            let op = detail
222                .op
223                .as_ref()
224                .map(op_name)
225                .unwrap_or_else(|| op_name(&RuleOp::default()));
226            (detail.value.clone(), op)
227        }
228    };
229    Some(UrlPathView { value, op })
230}
231
232/// TOML-form name for a `RuleOp`. The `Display` impl on `RuleOp`
233/// produces a human-readable form (`" == "`, `" starts with "`),
234/// which is good for log output but not for a stable identifier the
235/// GUI can match against. We translate to the same `snake_case` form
236/// `serde(rename_all = "snake_case")` produces on the way in, so the
237/// view round-trips back to the original TOML keyword.
238pub fn op_name(op: &RuleOp) -> String {
239    match op {
240        RuleOp::Equal => "equal",
241        RuleOp::NotEqual => "not_equal",
242        RuleOp::StartsWith => "starts_with",
243        RuleOp::NotStartsWith => "not_starts_with",
244        RuleOp::EndsWith => "ends_with",
245        RuleOp::NotEndsWith => "not_ends_with",
246        RuleOp::Contains => "contains",
247        RuleOp::NotContains => "not_contains",
248        RuleOp::WildCard => "wild_card",
249        RuleOp::Regex => "regex",
250        RuleOp::NotRegex => "not_regex",
251    }
252    .to_owned()
253}
254
255fn http_method_name(m: &HttpMethod) -> String {
256    m.as_str().to_owned()
257}
258
259pub fn build_respond_view(respond: &Respond) -> RespondView {
260    if let Some(path) = respond.file_path.as_ref() {
261        return RespondView::File {
262            path: path.clone(),
263            csv_records_key: respond.csv_records_key.clone(),
264        };
265    }
266    if let Some(text) = respond.text.as_ref() {
267        return RespondView::Text {
268            text: text.clone(),
269            status: respond.status,
270        };
271    }
272    if let Some(status) = respond.status {
273        return RespondView::Status { code: status };
274    }
275    // Fallback for an empty respond — not legal per validation, but
276    // surface it as an empty text body so the snapshot stays
277    // well-formed for GUIs that re-render mid-edit.
278    RespondView::Text {
279        text: String::new(),
280        status: None,
281    }
282}
283
284// -------------------------------------------------------------------
285// File tree (depth-1 eager) — RFC 005 filtering
286// -------------------------------------------------------------------
287
288/// Built-in directory names to exclude from `FileTreeView` by default.
289/// These are overwhelmingly build outputs / VCS metadata across common
290/// ecosystems; projects with unusual layouts can disable via
291/// `FileTreeFilter::builtin_excludes = false`.
292pub const BUILTIN_EXCLUDES: &[&str] = &[
293    "target",
294    "node_modules",
295    "dist",
296    "build",
297    "out",
298    "__pycache__",
299    ".venv",
300    "vendor",
301    ".cargo",
302    ".gradle",
303    ".idea",
304    ".vscode",
305];
306
307/// Filter options controlling which entries appear in [`FileTreeView`].
308///
309/// # Defaults
310///
311/// - `show_hidden = false` — hide dotfiles / dot-directories.
312/// - `builtin_excludes = true` — hide known build-output directories.
313/// - `extra_excludes = []` — no additional exclusions.
314/// - `include = []` — include everything (no inclusion filter).
315/// - `respect_gitignore = false` — do not parse `.gitignore` files.
316///
317/// The defaults are intentionally conservative: they hide the noise
318/// without requiring any configuration for the common case.
319#[derive(Clone, Debug)]
320pub struct FileTreeFilter {
321    /// When `false`, entries whose name starts with `.` are excluded.
322    pub show_hidden: bool,
323    /// When `true`, entries whose name appears in [`BUILTIN_EXCLUDES`]
324    /// are excluded.
325    pub builtin_excludes: bool,
326    /// Glob patterns for additional exclusions (RFC 019).
327    ///
328    /// Each entry is a `globset` glob pattern matched against the entry's
329    /// `file_name()` component only (not the full path). A trailing `/`
330    /// restricts the pattern to directories.
331    ///
332    /// **Breaking change from pre-5.11:** Previously these were exact-name
333    /// matches. Now they are glob patterns. Literal names continue to work
334    /// because a bare name with no metacharacters is a valid glob.
335    pub extra_excludes: Vec<String>,
336    /// If non-empty, only files whose name matches at least one pattern
337    /// (glob) are included. Directories are always kept so the user can
338    /// drill into them.
339    pub include: Vec<String>,
340    /// When `true`, parse `.gitignore` files at the directory being listed
341    /// and in its ancestors (up to the nearest `.git` directory), applying
342    /// the same ignore rules Git would (RFC 019). Off by default.
343    pub respect_gitignore: bool,
344}
345
346impl Default for FileTreeFilter {
347    fn default() -> Self {
348        Self {
349            show_hidden: false,
350            builtin_excludes: true,
351            extra_excludes: Vec::new(),
352            include: Vec::new(),
353            respect_gitignore: false,
354        }
355    }
356}
357
358impl FileTreeFilter {
359    /// Return `true` iff `name` should be kept (not filtered out).
360    ///
361    /// `path` is the full path to the entry (used for gitignore matching).
362    fn keep(
363        &self,
364        name: &str,
365        is_dir: bool,
366        path: &Path,
367        gitignore: Option<&ignore::gitignore::Gitignore>,
368    ) -> bool {
369        // Dotfile filter
370        if !self.show_hidden && name.starts_with('.') {
371            return false;
372        }
373        // Built-in exclude list
374        if self.builtin_excludes && BUILTIN_EXCLUDES.contains(&name) {
375            return false;
376        }
377        // Extra excludes (glob patterns, RFC 019)
378        if !self.extra_excludes.is_empty() {
379            let mut builder = globset::GlobSetBuilder::new();
380            for pat in &self.extra_excludes {
381                let full_pat = if is_dir && !pat.ends_with('/') {
382                    // A directory-only trailing-slash convention is handled
383                    // by matching dir names with or without the slash.
384                    pat.clone()
385                } else {
386                    pat.trim_end_matches('/').to_owned()
387                };
388                if let Ok(g) = globset::Glob::new(&full_pat) {
389                    builder.add(g);
390                }
391            }
392            if let Ok(set) = builder.build()
393                && set.is_match(name)
394            {
395                return false;
396            }
397        }
398        // .gitignore filter (RFC 019)
399        if let Some(gi) = gitignore {
400            let match_result = gi.matched(path, is_dir);
401            if match_result.is_ignore() {
402                return false;
403            }
404        }
405        // Include filter applies only to files; directories always pass.
406        if !is_dir && !self.include.is_empty() {
407            let mut builder = globset::GlobSetBuilder::new();
408            for pat in &self.include {
409                if let Ok(g) = globset::Glob::new(pat) {
410                    builder.add(g);
411                }
412            }
413            if let Ok(set) = builder.build()
414                && !set.is_match(name)
415            {
416                return false;
417            }
418        }
419        true
420    }
421}
422
423/// Build a `.gitignore`-aware ignore matcher for `dir` and its ancestors.
424///
425/// Walks up from `dir` to the filesystem root (stopping at a `.git`
426/// directory if one is found) and adds any `.gitignore` files encountered.
427/// Returns `None` if no `.gitignore` files were found or if parsing fails.
428fn build_gitignore_for(dir: &Path) -> Option<ignore::gitignore::Gitignore> {
429    let mut builder = ignore::gitignore::GitignoreBuilder::new(dir);
430    let mut added = false;
431    for ancestor in dir.ancestors() {
432        let candidate = ancestor.join(".gitignore");
433        if candidate.is_file() {
434            builder.add(&candidate);
435            added = true;
436        }
437        if ancestor.join(".git").is_dir() {
438            break;
439        }
440    }
441    if added { builder.build().ok() } else { None }
442}
443
444/// Build a depth-1 file-tree view rooted at `root`.
445///
446/// Applies [`FileTreeFilter::default()`] to exclude hidden entries and
447/// known build-output directories. Use [`build_file_tree_with`] to
448/// supply custom filter options.
449pub fn build_file_tree(root: &Path) -> Option<FileTreeView> {
450    build_file_tree_with(root, &FileTreeFilter::default())
451}
452
453/// Build a depth-1 file-tree view with an explicit [`FileTreeFilter`].
454///
455/// Returns `None` if the directory doesn't exist or can't be read.
456/// Subdirectories carry `children = Some(Vec::new())` to flag them as
457/// expandable-but-not-yet-expanded.
458pub fn build_file_tree_with(root: &Path, filter: &FileTreeFilter) -> Option<FileTreeView> {
459    let entries = std::fs::read_dir(root).ok()?;
460    let mut nodes: Vec<FileNodeView> = Vec::new();
461
462    // Build gitignore matcher once for the root directory (RFC 019).
463    let gitignore = if filter.respect_gitignore {
464        build_gitignore_for(root)
465    } else {
466        None
467    };
468
469    for entry in entries.flatten() {
470        let path = entry.path();
471        let name = path
472            .file_name()
473            .map(|n| n.to_string_lossy().into_owned())
474            .unwrap_or_default();
475        let metadata = match entry.metadata() {
476            Ok(m) => m,
477            Err(_) => continue,
478        };
479        let is_dir = metadata.is_dir();
480        let kind = if is_dir {
481            FileNodeKind::Directory
482        } else {
483            FileNodeKind::File
484        };
485
486        // Apply filter — root itself is never filtered, only its contents.
487        if !filter.keep(&name, is_dir, &path, gitignore.as_ref()) {
488            continue;
489        }
490
491        let route_hint = if matches!(kind, FileNodeKind::File) {
492            path.file_stem()
493                .map(|s| format!("/{}", s.to_string_lossy()))
494        } else {
495            None
496        };
497
498        let children = match kind {
499            FileNodeKind::Directory => Some(Vec::new()),
500            FileNodeKind::File => None,
501        };
502
503        nodes.push(FileNodeView {
504            name,
505            path: path.to_string_lossy().into_owned(),
506            kind,
507            route_hint,
508            children,
509        });
510    }
511
512    // Stable rendering: directories first, then files; alphabetical within each group.
513    nodes.sort_by(|a, b| match (&a.kind, &b.kind) {
514        (FileNodeKind::Directory, FileNodeKind::File) => std::cmp::Ordering::Less,
515        (FileNodeKind::File, FileNodeKind::Directory) => std::cmp::Ordering::Greater,
516        _ => a.name.cmp(&b.name),
517    });
518
519    Some(FileTreeView {
520        root_path: root.to_string_lossy().into_owned(),
521        entries: nodes,
522    })
523}
524
525/// Same shape as `build_file_tree`, but for ad-hoc subdirectory
526/// expansion. Applies [`FileTreeFilter::default()`].
527pub fn list_directory(path: &Path) -> Vec<FileNodeView> {
528    list_directory_with(path, &FileTreeFilter::default())
529}
530
531/// Subdirectory expansion with an explicit filter.
532pub fn list_directory_with(path: &Path, filter: &FileTreeFilter) -> Vec<FileNodeView> {
533    build_file_tree_with(path, filter)
534        .map(|t| t.entries)
535        .unwrap_or_default()
536}
537
538// -------------------------------------------------------------------
539// Script routes
540// -------------------------------------------------------------------
541
542/// Build a `ScriptRouteView` from a middleware path and its index in
543/// `service.middlewares_file_paths`.
544pub fn build_script_route_view(index: usize, source_file: &str) -> ScriptRouteView {
545    let display_name = Path::new(source_file)
546        .file_name()
547        .map(|n| n.to_string_lossy().into_owned())
548        .unwrap_or_else(|| source_file.to_owned());
549
550    ScriptRouteView {
551        index,
552        source_file: source_file.to_owned(),
553        display_name,
554    }
555}