1use 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
40pub 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 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 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 let op_clean = body_op_name(stmt.op.as_ref().unwrap_or(&BodyOperator::Equal));
157 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; views.push(BodyConditionView {
162 kind: kind_str.to_owned(),
163 path: path.clone(),
164 op: op_clean,
165 value,
166 });
167 }
168 }
169 views.sort_by(|a, b| a.path.cmp(&b.path));
171 views
172}
173
174pub 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
232pub 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 RespondView::Text {
279 text: String::new(),
280 status: None,
281 }
282}
283
284pub 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#[derive(Clone, Debug)]
320pub struct FileTreeFilter {
321 pub show_hidden: bool,
323 pub builtin_excludes: bool,
326 pub extra_excludes: Vec<String>,
336 pub include: Vec<String>,
340 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 fn keep(
363 &self,
364 name: &str,
365 is_dir: bool,
366 path: &Path,
367 gitignore: Option<&ignore::gitignore::Gitignore>,
368 ) -> bool {
369 if !self.show_hidden && name.starts_with('.') {
371 return false;
372 }
373 if self.builtin_excludes && BUILTIN_EXCLUDES.contains(&name) {
375 return false;
376 }
377 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 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 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 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
423fn 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
444pub fn build_file_tree(root: &Path) -> Option<FileTreeView> {
450 build_file_tree_with(root, &FileTreeFilter::default())
451}
452
453pub 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 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 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 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
525pub fn list_directory(path: &Path) -> Vec<FileNodeView> {
528 list_directory_with(path, &FileTreeFilter::default())
529}
530
531pub 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
538pub 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}