1use std::{
31 collections::HashMap,
32 path::{Path, PathBuf},
33};
34
35use apimock_routing::{RoutingError, RuleSet};
36
37use crate::{
38 Config,
39 error::{ApplyError, ConfigError, SaveError, WorkspaceError},
40 view::{
41 ApplyResult, ConfigFileKind, ConfigFileView, ConfigNodeView, Diagnostic, EditCommand,
42 EditValue, NodeId, NodeKind, NodeValidation, SaveResult, Severity, ValidationIssue,
43 ValidationReport, WorkspaceSnapshot,
44 },
45};
46
47pub struct Workspace {
61 root_path: PathBuf,
63 config: Config,
67 ids: IdIndex,
69 diagnostics: Vec<Diagnostic>,
72 baseline_files: HashMap<PathBuf, String>,
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
93enum NodeAddress {
94 Root,
96 RuleSet { rule_set: usize },
98 Rule { rule_set: usize, rule: usize },
100 Respond { rule_set: usize, rule: usize },
102 Middleware { middleware: usize },
105 FallbackRespondDir,
107}
108
109#[derive(Default)]
110struct IdIndex {
111 id_to_address: HashMap<NodeId, NodeAddress>,
112 address_to_id: HashMap<NodeAddress, NodeId>,
113}
114
115impl IdIndex {
116 fn insert(&mut self, address: NodeAddress) -> NodeId {
117 if let Some(&id) = self.address_to_id.get(&address) {
118 return id;
119 }
120 let id = NodeId::new();
121 self.id_to_address.insert(id, address);
122 self.address_to_id.insert(address, id);
123 id
124 }
125
126 #[allow(dead_code)]
128 fn lookup(&self, id: NodeId) -> Option<NodeAddress> {
129 self.id_to_address.get(&id).copied()
130 }
131
132 fn id_for(&self, address: NodeAddress) -> Option<NodeId> {
133 self.address_to_id.get(&address).copied()
134 }
135}
136
137impl Workspace {
138 pub fn load(root: PathBuf) -> Result<Self, WorkspaceError> {
145 let resolved = resolve_root(&root)?;
146
147 let config_path_string = resolved.to_string_lossy().into_owned();
153 let config = Config::new(Some(&config_path_string), None).map_err(WorkspaceError::from)?;
154
155 let mut baseline_files: HashMap<PathBuf, String> = HashMap::new();
175 baseline_files.insert(
176 resolved.clone(),
177 crate::toml_writer::render_apimock_toml(&config),
178 );
179 for rule_set in config.service.rule_sets.iter() {
180 let path = PathBuf::from(rule_set.file_path.as_str());
181 baseline_files.insert(
182 path,
183 crate::toml_writer::render_rule_set_toml(rule_set),
184 );
185 }
186
187 let mut workspace = Self {
188 root_path: resolved,
189 config,
190 ids: IdIndex::default(),
191 diagnostics: Vec::new(),
192 baseline_files,
193 };
194 workspace.seed_ids();
195 Ok(workspace)
196 }
197
198 fn seed_ids(&mut self) {
215 self.ids.insert(NodeAddress::Root);
217
218 self.ids.insert(NodeAddress::FallbackRespondDir);
221
222 for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
224 self.ids.insert(NodeAddress::RuleSet { rule_set: rs_idx });
225 for (rule_idx, _rule) in rule_set.rules.iter().enumerate() {
226 self.ids.insert(NodeAddress::Rule {
227 rule_set: rs_idx,
228 rule: rule_idx,
229 });
230 self.ids.insert(NodeAddress::Respond {
231 rule_set: rs_idx,
232 rule: rule_idx,
233 });
234 }
235 }
236
237 if let Some(paths) = self.config.service.middlewares_file_paths.as_ref() {
239 for mw_idx in 0..paths.len() {
240 self.ids
241 .insert(NodeAddress::Middleware { middleware: mw_idx });
242 }
243 }
244 }
245
246 pub fn snapshot(&self) -> WorkspaceSnapshot {
256 let mut files: Vec<ConfigFileView> = Vec::new();
257
258 if let Some(root_nodes) = self.root_file_nodes() {
260 files.push(root_nodes);
261 }
262
263 for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
265 files.push(self.rule_set_file_view(rs_idx, rule_set));
266 }
267
268 if let Some(paths) = self.config.service.middlewares_file_paths.as_ref() {
271 for (mw_idx, mw_path) in paths.iter().enumerate() {
272 let abs = self.resolve_relative(mw_path);
273 let id = self
274 .ids
275 .id_for(NodeAddress::Middleware { middleware: mw_idx })
276 .expect("middleware id seeded at load");
277 let node = ConfigNodeView {
278 id,
279 source_file: abs.clone(),
280 toml_path: format!("service.middlewares[{}]", mw_idx),
281 display_name: mw_path.clone(),
282 kind: NodeKind::Script,
283 validation: NodeValidation::ok(),
284 };
285 files.push(ConfigFileView {
286 path: abs.clone(),
287 display_name: file_basename(&abs),
288 kind: ConfigFileKind::Middleware,
289 nodes: vec![node],
290 });
291 }
292 }
293
294 let fallback_dir = self.config.service.fallback_respond_dir.as_str();
299 let fallback_abs = self.resolve_relative(fallback_dir);
300 let file_tree = apimock_routing::view::build::build_file_tree(&fallback_abs);
301
302 let script_routes: Vec<apimock_routing::view::ScriptRouteView> = self
303 .config
304 .service
305 .middlewares_file_paths
306 .as_ref()
307 .map(|paths| {
308 paths
309 .iter()
310 .enumerate()
311 .map(|(idx, p)| apimock_routing::view::build::build_script_route_view(idx, p))
312 .collect()
313 })
314 .unwrap_or_default();
315
316 let routes = apimock_routing::view::build::build_route_catalog(
317 &self.config.service.rule_sets,
318 Some(fallback_dir),
319 file_tree,
320 script_routes,
321 );
322
323 WorkspaceSnapshot {
324 files,
325 routes,
326 diagnostics: self.diagnostics.clone(),
327 }
328 }
329
330 pub fn apply(&mut self, cmd: EditCommand) -> Result<ApplyResult, ApplyError> {
350 let (changed_nodes, requires_reload) = match cmd {
351 EditCommand::AddRuleSet { path } => {
352 let ids = self.cmd_add_rule_set(path)?;
353 (ids, true)
354 }
355 EditCommand::RemoveRuleSet { id } => {
356 let ids = self.cmd_remove_rule_set(id)?;
357 (ids, true)
358 }
359 EditCommand::AddRule { parent, rule } => {
360 let ids = self.cmd_add_rule(parent, rule)?;
361 (ids, true)
362 }
363 EditCommand::UpdateRule { id, rule } => {
364 let ids = self.cmd_update_rule(id, rule)?;
365 (ids, true)
366 }
367 EditCommand::DeleteRule { id } => {
368 let ids = self.cmd_delete_rule(id)?;
369 (ids, true)
370 }
371 EditCommand::MoveRule { id, new_index } => {
372 let ids = self.cmd_move_rule(id, new_index)?;
373 (ids, true)
374 }
375 EditCommand::UpdateRespond { id, respond } => {
376 let ids = self.cmd_update_respond(id, respond)?;
377 (ids, true)
378 }
379 EditCommand::UpdateRootSetting { key, value } => {
380 let ids = self.cmd_update_root_setting(key, value)?;
381 (ids, true)
387 }
388 };
389
390 let diagnostics = self.collect_diagnostics();
395
396 Ok(ApplyResult {
397 changed_nodes,
398 diagnostics,
399 requires_reload,
400 })
401 }
402
403 fn cmd_add_rule_set(&mut self, path: String) -> Result<Vec<NodeId>, ApplyError> {
406 let relative_dir = self.config_relative_dir().map_err(internal_path_err)?;
409 let joined = Path::new(&relative_dir).join(&path);
410 let path_str = joined.to_str().ok_or_else(|| ApplyError::InvalidPayload {
411 reason: format!(
412 "path contains non-UTF-8 bytes: {}",
413 joined.to_string_lossy()
414 ),
415 })?;
416
417 let next_idx = self.config.service.rule_sets.len();
418 let new_rule_set = RuleSet::new(path_str, relative_dir.as_str(), next_idx)
419 .map_err(|e| ApplyError::InvalidPayload {
420 reason: format!("failed to load rule set `{}`: {}", path, e),
421 })?;
422
423 let file_paths = self
426 .config
427 .service
428 .rule_sets_file_paths
429 .get_or_insert_with(Vec::new);
430 file_paths.push(path.clone());
431
432 let new_len = self.config.service.rule_sets.len() + 1;
433 self.config.service.rule_sets.push(new_rule_set);
434
435 let rs_addr = NodeAddress::RuleSet {
437 rule_set: next_idx,
438 };
439 let rs_id = self.ids.insert(rs_addr);
440 let mut changed = vec![rs_id];
441 let new_rs = &self.config.service.rule_sets[next_idx];
442 for rule_idx in 0..new_rs.rules.len() {
443 let r_id = self.ids.insert(NodeAddress::Rule {
444 rule_set: next_idx,
445 rule: rule_idx,
446 });
447 let resp_id = self.ids.insert(NodeAddress::Respond {
448 rule_set: next_idx,
449 rule: rule_idx,
450 });
451 changed.push(r_id);
452 changed.push(resp_id);
453 }
454 debug_assert_eq!(new_len, self.config.service.rule_sets.len());
457
458 Ok(changed)
459 }
460
461 fn cmd_remove_rule_set(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
462 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
463 let NodeAddress::RuleSet { rule_set: idx } = addr else {
464 return Err(ApplyError::WrongNodeKind {
465 id,
466 reason: "expected a rule set id".to_owned(),
467 });
468 };
469
470 let len = self.config.service.rule_sets.len();
471 if idx >= len {
472 return Err(ApplyError::InvalidPayload {
473 reason: format!("rule set index {} out of range (len={})", idx, len),
474 });
475 }
476
477 let mut changed: Vec<NodeId> = Vec::new();
480 changed.push(id);
482 if let Some(removed_rs) = self.config.service.rule_sets.get(idx) {
483 for rule_idx in 0..removed_rs.rules.len() {
484 if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
485 rule_set: idx,
486 rule: rule_idx,
487 }) {
488 changed.push(r_id);
489 }
490 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
491 rule_set: idx,
492 rule: rule_idx,
493 }) {
494 changed.push(resp_id);
495 }
496 }
497 }
498
499 self.config.service.rule_sets.remove(idx);
501 if let Some(paths) = self.config.service.rule_sets_file_paths.as_mut() {
502 if idx < paths.len() {
503 paths.remove(idx);
504 }
505 }
506
507 self.shift_rule_sets_down(idx);
512
513 for shifted_idx in idx..self.config.service.rule_sets.len() {
517 if let Some(shifted_id) = self
518 .ids
519 .id_for(NodeAddress::RuleSet {
520 rule_set: shifted_idx,
521 })
522 {
523 if !changed.contains(&shifted_id) {
524 changed.push(shifted_id);
525 }
526 }
527 }
528
529 Ok(changed)
530 }
531
532 fn cmd_add_rule(
533 &mut self,
534 parent: NodeId,
535 rule_payload: crate::view::RulePayload,
536 ) -> Result<Vec<NodeId>, ApplyError> {
537 let addr = self
538 .ids
539 .lookup(parent)
540 .ok_or(ApplyError::UnknownNode { id: parent })?;
541 let NodeAddress::RuleSet { rule_set: rs_idx } = addr else {
542 return Err(ApplyError::WrongNodeKind {
543 id: parent,
544 reason: "expected a rule set id (parent for AddRule must be a rule set)".to_owned(),
545 });
546 };
547
548 let rule_set = self
549 .config
550 .service
551 .rule_sets
552 .get_mut(rs_idx)
553 .ok_or_else(|| ApplyError::InvalidPayload {
554 reason: format!("rule set index {} out of range", rs_idx),
555 })?;
556
557 let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
558 let new_rule_idx = rule_set.rules.len();
559 rule_set.rules.push(new_rule);
560
561 let r_id = self.ids.insert(NodeAddress::Rule {
562 rule_set: rs_idx,
563 rule: new_rule_idx,
564 });
565 let resp_id = self.ids.insert(NodeAddress::Respond {
566 rule_set: rs_idx,
567 rule: new_rule_idx,
568 });
569 Ok(vec![parent, r_id, resp_id])
570 }
571
572 fn cmd_update_rule(
573 &mut self,
574 id: NodeId,
575 rule_payload: crate::view::RulePayload,
576 ) -> Result<Vec<NodeId>, ApplyError> {
577 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
578 let NodeAddress::Rule {
579 rule_set: rs_idx,
580 rule: rule_idx,
581 } = addr
582 else {
583 return Err(ApplyError::WrongNodeKind {
584 id,
585 reason: "expected a rule id".to_owned(),
586 });
587 };
588
589 let rule_set = self
590 .config
591 .service
592 .rule_sets
593 .get_mut(rs_idx)
594 .ok_or_else(|| ApplyError::InvalidPayload {
595 reason: format!("rule set index {} out of range", rs_idx),
596 })?;
597
598 let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
599 *rule_set
600 .rules
601 .get_mut(rule_idx)
602 .ok_or_else(|| ApplyError::InvalidPayload {
603 reason: format!("rule index {} out of range", rule_idx),
604 })? = new_rule;
605
606 let resp_id = self
607 .ids
608 .id_for(NodeAddress::Respond {
609 rule_set: rs_idx,
610 rule: rule_idx,
611 })
612 .unwrap_or_else(NodeId::new);
613 Ok(vec![id, resp_id])
614 }
615
616 fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
617 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
618 let NodeAddress::Rule {
619 rule_set: rs_idx,
620 rule: rule_idx,
621 } = addr
622 else {
623 return Err(ApplyError::WrongNodeKind {
624 id,
625 reason: "expected a rule id".to_owned(),
626 });
627 };
628
629 let rule_set = self
630 .config
631 .service
632 .rule_sets
633 .get_mut(rs_idx)
634 .ok_or_else(|| ApplyError::InvalidPayload {
635 reason: format!("rule set index {} out of range", rs_idx),
636 })?;
637
638 if rule_idx >= rule_set.rules.len() {
639 return Err(ApplyError::InvalidPayload {
640 reason: format!("rule index {} out of range", rule_idx),
641 });
642 }
643
644 let mut changed: Vec<NodeId> = Vec::new();
646 changed.push(id);
647 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
648 rule_set: rs_idx,
649 rule: rule_idx,
650 }) {
651 changed.push(resp_id);
652 }
653
654 rule_set.rules.remove(rule_idx);
655 self.shift_rules_down(rs_idx, rule_idx);
656
657 let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
659 for shifted_idx in rule_idx..new_rule_count {
660 if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
661 rule_set: rs_idx,
662 rule: shifted_idx,
663 }) {
664 if !changed.contains(&r_id) {
665 changed.push(r_id);
666 }
667 }
668 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
669 rule_set: rs_idx,
670 rule: shifted_idx,
671 }) {
672 if !changed.contains(&resp_id) {
673 changed.push(resp_id);
674 }
675 }
676 }
677
678 Ok(changed)
679 }
680
681 fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
682 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
683 let NodeAddress::Rule {
684 rule_set: rs_idx,
685 rule: old_idx,
686 } = addr
687 else {
688 return Err(ApplyError::WrongNodeKind {
689 id,
690 reason: "expected a rule id".to_owned(),
691 });
692 };
693
694 let rule_set = self
695 .config
696 .service
697 .rule_sets
698 .get_mut(rs_idx)
699 .ok_or_else(|| ApplyError::InvalidPayload {
700 reason: format!("rule set index {} out of range", rs_idx),
701 })?;
702
703 if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
704 return Err(ApplyError::InvalidPayload {
705 reason: format!(
706 "move out of bounds: old_idx={}, new_index={}, len={}",
707 old_idx,
708 new_index,
709 rule_set.rules.len()
710 ),
711 });
712 }
713 if old_idx == new_index {
714 return Ok(vec![id]);
715 }
716
717 let rule = rule_set.rules.remove(old_idx);
719 rule_set.rules.insert(new_index, rule);
720
721 self.reorder_rule_ids(rs_idx, old_idx, new_index);
726
727 let lo = old_idx.min(new_index);
730 let hi = old_idx.max(new_index);
731 let mut changed: Vec<NodeId> = Vec::new();
732 for idx in lo..=hi {
733 if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
734 rule_set: rs_idx,
735 rule: idx,
736 }) {
737 changed.push(r_id);
738 }
739 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
740 rule_set: rs_idx,
741 rule: idx,
742 }) {
743 changed.push(resp_id);
744 }
745 }
746 Ok(changed)
747 }
748
749 fn cmd_update_respond(
750 &mut self,
751 id: NodeId,
752 respond: crate::view::RespondPayload,
753 ) -> Result<Vec<NodeId>, ApplyError> {
754 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
755 let NodeAddress::Respond {
756 rule_set: rs_idx,
757 rule: rule_idx,
758 } = addr
759 else {
760 return Err(ApplyError::WrongNodeKind {
761 id,
762 reason: "expected a respond id".to_owned(),
763 });
764 };
765
766 let rule = self
767 .config
768 .service
769 .rule_sets
770 .get_mut(rs_idx)
771 .and_then(|rs| rs.rules.get_mut(rule_idx))
772 .ok_or_else(|| ApplyError::InvalidPayload {
773 reason: format!(
774 "rule at rule_set={}, rule={} not found",
775 rs_idx, rule_idx
776 ),
777 })?;
778
779 rule.respond = build_respond_from_payload(respond);
780
781 let rule_set = &self.config.service.rule_sets[rs_idx];
784 let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
785 self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
786
787 Ok(vec![id])
788 }
789
790 fn cmd_update_root_setting(
791 &mut self,
792 key: crate::view::RootSettingKey,
793 value: EditValue,
794 ) -> Result<Vec<NodeId>, ApplyError> {
795 use crate::view::RootSettingKey::*;
796
797 match key {
798 ListenerIpAddress => {
799 let s = value_as_string(&value)?;
800 let listener = self.config.listener.get_or_insert_with(Default::default);
801 listener.ip_address = s;
802 }
803 ListenerPort => {
804 let n = value_as_integer(&value)?;
805 if !(0..=u16::MAX as i64).contains(&n) {
806 return Err(ApplyError::InvalidPayload {
807 reason: format!("port {} not in 0..=65535", n),
808 });
809 }
810 let listener = self.config.listener.get_or_insert_with(Default::default);
811 listener.port = n as u16;
812 }
813 ServiceFallbackRespondDir => {
814 let s = value_as_string(&value)?;
815 self.config.service.fallback_respond_dir = s;
816 }
817 ServiceStrategy => {
818 let s = value_as_string(&value)?;
819 match s.as_str() {
823 "first_match" => {
824 self.config.service.strategy =
825 Some(apimock_routing::Strategy::FirstMatch);
826 }
827 other => {
828 return Err(ApplyError::InvalidPayload {
829 reason: format!("unknown strategy: {}", other),
830 });
831 }
832 }
833 }
834 }
835
836 let id = self
838 .ids
839 .id_for(NodeAddress::Root)
840 .expect("root id seeded at load");
841 Ok(vec![id])
842 }
843
844 fn shift_rule_sets_down(&mut self, removed_idx: usize) {
849 let new_rs_count = self.config.service.rule_sets.len();
855
856 let mut stale: Vec<NodeAddress> = Vec::new();
860 stale.push(NodeAddress::RuleSet {
861 rule_set: removed_idx,
862 });
863 for old_idx in removed_idx..new_rs_count + 1 {
865 stale.push(NodeAddress::RuleSet { rule_set: old_idx });
866 }
869
870 let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
874 for (&addr, &id) in self.ids.address_to_id.iter() {
875 match addr {
876 NodeAddress::RuleSet { rule_set } if rule_set >= removed_idx => {
877 to_migrate.push((id, addr));
878 }
879 NodeAddress::Rule { rule_set, .. } if rule_set >= removed_idx => {
880 to_migrate.push((id, addr));
881 }
882 NodeAddress::Respond { rule_set, .. } if rule_set >= removed_idx => {
883 to_migrate.push((id, addr));
884 }
885 _ => {}
886 }
887 }
888
889 for (id, addr) in &to_migrate {
890 self.ids.address_to_id.remove(addr);
891 self.ids.id_to_address.remove(id);
892 }
893
894 for (id, addr) in to_migrate {
897 let new_addr = match addr {
898 NodeAddress::RuleSet { rule_set } => {
899 if rule_set == removed_idx {
900 continue; }
902 NodeAddress::RuleSet {
903 rule_set: rule_set - 1,
904 }
905 }
906 NodeAddress::Rule { rule_set, rule } => {
907 if rule_set == removed_idx {
908 continue;
909 }
910 NodeAddress::Rule {
911 rule_set: rule_set - 1,
912 rule,
913 }
914 }
915 NodeAddress::Respond { rule_set, rule } => {
916 if rule_set == removed_idx {
917 continue;
918 }
919 NodeAddress::Respond {
920 rule_set: rule_set - 1,
921 rule,
922 }
923 }
924 other => other,
925 };
926 self.ids.id_to_address.insert(id, new_addr);
927 self.ids.address_to_id.insert(new_addr, id);
928 }
929 }
930
931 fn shift_rules_down(&mut self, rule_set_idx: usize, removed_rule_idx: usize) {
934 let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
935 for (&addr, &id) in self.ids.address_to_id.iter() {
936 match addr {
937 NodeAddress::Rule { rule_set, rule }
938 if rule_set == rule_set_idx && rule >= removed_rule_idx =>
939 {
940 to_migrate.push((id, addr));
941 }
942 NodeAddress::Respond { rule_set, rule }
943 if rule_set == rule_set_idx && rule >= removed_rule_idx =>
944 {
945 to_migrate.push((id, addr));
946 }
947 _ => {}
948 }
949 }
950
951 for (id, addr) in &to_migrate {
952 self.ids.address_to_id.remove(addr);
953 self.ids.id_to_address.remove(id);
954 }
955
956 for (id, addr) in to_migrate {
957 let new_addr = match addr {
958 NodeAddress::Rule { rule_set, rule } => {
959 if rule == removed_rule_idx {
960 continue;
961 }
962 NodeAddress::Rule {
963 rule_set,
964 rule: rule - 1,
965 }
966 }
967 NodeAddress::Respond { rule_set, rule } => {
968 if rule == removed_rule_idx {
969 continue;
970 }
971 NodeAddress::Respond {
972 rule_set,
973 rule: rule - 1,
974 }
975 }
976 other => other,
977 };
978 self.ids.id_to_address.insert(id, new_addr);
979 self.ids.address_to_id.insert(new_addr, id);
980 }
981 }
982
983 fn reorder_rule_ids(&mut self, rule_set_idx: usize, old_idx: usize, new_idx: usize) {
986 let rule_count = self.config.service.rule_sets[rule_set_idx].rules.len();
988 let mut rule_ids: Vec<Option<NodeId>> = (0..rule_count)
989 .map(|r| {
990 self.ids.id_for(NodeAddress::Rule {
991 rule_set: rule_set_idx,
992 rule: r,
993 })
994 })
995 .collect();
996 let mut resp_ids: Vec<Option<NodeId>> = (0..rule_count)
997 .map(|r| {
998 self.ids.id_for(NodeAddress::Respond {
999 rule_set: rule_set_idx,
1000 rule: r,
1001 })
1002 })
1003 .collect();
1004
1005 let moving_r = rule_ids.remove(old_idx);
1011 rule_ids.insert(new_idx, moving_r);
1012 let moving_resp = resp_ids.remove(old_idx);
1013 resp_ids.insert(new_idx, moving_resp);
1014
1015 for r in 0..rule_count {
1017 let rule_addr = NodeAddress::Rule {
1018 rule_set: rule_set_idx,
1019 rule: r,
1020 };
1021 let resp_addr = NodeAddress::Respond {
1022 rule_set: rule_set_idx,
1023 rule: r,
1024 };
1025 if let Some(prev_id) = self.ids.address_to_id.remove(&rule_addr) {
1026 self.ids.id_to_address.remove(&prev_id);
1027 }
1028 if let Some(prev_id) = self.ids.address_to_id.remove(&resp_addr) {
1029 self.ids.id_to_address.remove(&prev_id);
1030 }
1031 }
1032 for (r, id_opt) in rule_ids.into_iter().enumerate() {
1033 let addr = NodeAddress::Rule {
1034 rule_set: rule_set_idx,
1035 rule: r,
1036 };
1037 let id = id_opt.unwrap_or_else(NodeId::new);
1038 self.ids.id_to_address.insert(id, addr);
1039 self.ids.address_to_id.insert(addr, id);
1040 }
1041 for (r, id_opt) in resp_ids.into_iter().enumerate() {
1042 let addr = NodeAddress::Respond {
1043 rule_set: rule_set_idx,
1044 rule: r,
1045 };
1046 let id = id_opt.unwrap_or_else(NodeId::new);
1047 self.ids.id_to_address.insert(id, addr);
1048 self.ids.address_to_id.insert(addr, id);
1049 }
1050 }
1051
1052 fn config_relative_dir(&self) -> Result<String, ConfigError> {
1053 self.config.current_dir_to_parent_dir_relative_path()
1054 }
1055
1056 fn collect_diagnostics(&self) -> Vec<Diagnostic> {
1060 let mut out: Vec<Diagnostic> = Vec::new();
1061 for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
1062 for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
1063 let nv = respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx);
1064 if nv.ok {
1065 continue;
1066 }
1067 let resp_id = self.ids.id_for(NodeAddress::Respond {
1068 rule_set: rs_idx,
1069 rule: rule_idx,
1070 });
1071 for issue in nv.issues {
1072 out.push(Diagnostic {
1073 node_id: resp_id,
1074 file: Some(PathBuf::from(rule_set.file_path.as_str())),
1075 severity: issue.severity,
1076 message: issue.message,
1077 });
1078 }
1079 }
1080 }
1081
1082 if !Path::new(self.config.service.fallback_respond_dir.as_str()).exists() {
1084 out.push(Diagnostic {
1085 node_id: self.ids.id_for(NodeAddress::FallbackRespondDir),
1086 file: Some(self.root_path.clone()),
1087 severity: Severity::Error,
1088 message: format!(
1089 "fallback_respond_dir does not exist: {}",
1090 self.config.service.fallback_respond_dir
1091 ),
1092 });
1093 }
1094
1095 out
1096 }
1097
1098 pub fn validate(&self) -> ValidationReport {
1106 let diagnostics = self.collect_diagnostics();
1107 let is_valid = !diagnostics
1108 .iter()
1109 .any(|d| matches!(d.severity, Severity::Error));
1110 ValidationReport {
1111 diagnostics,
1112 is_valid,
1113 }
1114 }
1115
1116 pub fn save(&mut self) -> Result<SaveResult, SaveError> {
1147 let new_root_toml = crate::toml_writer::render_apimock_toml(&self.config);
1149
1150 let mut rule_set_renders: Vec<(PathBuf, String)> = Vec::new();
1151 for rule_set in self.config.service.rule_sets.iter() {
1152 let path = PathBuf::from(rule_set.file_path.as_str());
1153 let text = crate::toml_writer::render_rule_set_toml(rule_set);
1154 rule_set_renders.push((path, text));
1155 }
1156
1157 let mut to_write: Vec<(PathBuf, String)> = Vec::new();
1159
1160 let baseline_root = self.baseline_files.get(&self.root_path);
1161 if baseline_root.map(String::as_str) != Some(new_root_toml.as_str()) {
1162 to_write.push((self.root_path.clone(), new_root_toml.clone()));
1163 }
1164 for (path, text) in rule_set_renders.iter() {
1165 let baseline = self.baseline_files.get(path);
1166 if baseline.map(String::as_str) != Some(text.as_str()) {
1167 to_write.push((path.clone(), text.clone()));
1168 }
1169 }
1170
1171 let mut written: Vec<PathBuf> = Vec::with_capacity(to_write.len());
1173 for (path, text) in &to_write {
1174 atomic_write(path, text)?;
1175 written.push(path.clone());
1176 }
1177
1178 let diff_summary = self.compute_diff_summary();
1183
1184 for (path, text) in to_write.into_iter() {
1186 self.baseline_files.insert(path, text);
1187 }
1188
1189 let listener_changed = written.contains(&self.root_path);
1194 let requires_reload = listener_changed || !written.is_empty();
1195
1196 Ok(SaveResult {
1197 changed_files: written,
1198 diff_summary,
1199 requires_reload,
1200 })
1201 }
1202
1203 fn compute_diff_summary(&self) -> Vec<crate::view::DiffItem> {
1226 use crate::view::{DiffItem, DiffKind};
1227
1228 let mut out = Vec::new();
1229
1230 for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
1232 let path = PathBuf::from(rule_set.file_path.as_str());
1233 let rendered = crate::toml_writer::render_rule_set_toml(rule_set);
1234 let baseline_text = self.baseline_files.get(&path);
1235 let baseline_matches = baseline_text
1236 .map(|s| s.as_str() == rendered.as_str())
1237 .unwrap_or(false);
1238 if baseline_matches {
1239 continue;
1240 }
1241
1242 if let Some(baseline) = baseline_text {
1243 self.append_per_rule_diff(rs_idx, rule_set, baseline, &mut out);
1245 } else {
1246 if let Some(rs_id) = self.ids.id_for(NodeAddress::RuleSet { rule_set: rs_idx }) {
1249 out.push(DiffItem {
1250 kind: DiffKind::Added,
1251 target: rs_id,
1252 summary: format!(
1253 "rule set #{} ({}): rules={}",
1254 rs_idx + 1,
1255 file_basename(&path),
1256 rule_set.rules.len(),
1257 ),
1258 });
1259 }
1260 }
1261 }
1262
1263 let root_rendered = crate::toml_writer::render_apimock_toml(&self.config);
1265 let root_baseline_matches = self
1266 .baseline_files
1267 .get(&self.root_path)
1268 .map(|s| s.as_str() == root_rendered.as_str())
1269 .unwrap_or(false);
1270 if !root_baseline_matches {
1271 if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
1272 out.push(DiffItem {
1273 kind: DiffKind::Updated,
1274 target: root_id,
1275 summary: format!(
1276 "{}: listener / log / service",
1277 file_basename(&self.root_path)
1278 ),
1279 });
1280 }
1281 }
1282
1283 out
1284 }
1285
1286 fn append_per_rule_diff(
1300 &self,
1301 rs_idx: usize,
1302 rule_set: &apimock_routing::RuleSet,
1303 baseline_text: &str,
1304 out: &mut Vec<crate::view::DiffItem>,
1305 ) {
1306 use crate::view::{DiffItem, DiffKind};
1307 use toml::Value;
1308
1309 let baseline_value: Value = match toml::from_str(baseline_text) {
1311 Ok(v) => v,
1312 Err(_) => return, };
1314 let baseline_rules: &[Value] = match baseline_value
1315 .get("rules")
1316 .and_then(|v| v.as_array())
1317 {
1318 Some(arr) => arr.as_slice(),
1319 None => &[],
1320 };
1321
1322 let cur_len = rule_set.rules.len();
1323 let base_len = baseline_rules.len();
1324 let common = cur_len.min(base_len);
1325
1326 for rule_idx in 0..common {
1328 let cur_rendered = rule_to_string(&rule_set.rules[rule_idx]);
1329 let base_rendered = toml::to_string_pretty(&baseline_rules[rule_idx])
1330 .unwrap_or_default();
1331 if cur_rendered == base_rendered {
1332 continue;
1333 }
1334 let target = self
1335 .ids
1336 .id_for(NodeAddress::Rule {
1337 rule_set: rs_idx,
1338 rule: rule_idx,
1339 })
1340 .unwrap_or_else(NodeId::new);
1341 out.push(DiffItem {
1342 kind: DiffKind::Updated,
1343 target,
1344 summary: format!(
1345 "rule #{} in rule set #{}",
1346 rule_idx + 1,
1347 rs_idx + 1
1348 ),
1349 });
1350 }
1351
1352 for rule_idx in common..cur_len {
1354 let target = self
1355 .ids
1356 .id_for(NodeAddress::Rule {
1357 rule_set: rs_idx,
1358 rule: rule_idx,
1359 })
1360 .unwrap_or_else(NodeId::new);
1361 out.push(DiffItem {
1362 kind: DiffKind::Added,
1363 target,
1364 summary: format!(
1365 "added rule #{} in rule set #{}",
1366 rule_idx + 1,
1367 rs_idx + 1
1368 ),
1369 });
1370 }
1371
1372 for rule_idx in common..base_len {
1377 out.push(DiffItem {
1378 kind: DiffKind::Removed,
1379 target: NodeId::new(),
1380 summary: format!(
1381 "removed rule #{} from rule set #{}",
1382 rule_idx + 1,
1383 rs_idx + 1
1384 ),
1385 });
1386 }
1387 }
1388
1389 pub fn has_unsaved_changes(&self) -> bool {
1397 let root_text = crate::toml_writer::render_apimock_toml(&self.config);
1398 if self
1399 .baseline_files
1400 .get(&self.root_path)
1401 .map(|s| s.as_str())
1402 != Some(root_text.as_str())
1403 {
1404 return true;
1405 }
1406 for rule_set in self.config.service.rule_sets.iter() {
1407 let path = PathBuf::from(rule_set.file_path.as_str());
1408 let text = crate::toml_writer::render_rule_set_toml(rule_set);
1409 if self
1410 .baseline_files
1411 .get(&path)
1412 .map(|s| s.as_str())
1413 != Some(text.as_str())
1414 {
1415 return true;
1416 }
1417 }
1418 false
1419 }
1420
1421 fn root_file_nodes(&self) -> Option<ConfigFileView> {
1423 let mut nodes = Vec::new();
1424
1425 if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
1426 nodes.push(ConfigNodeView {
1427 id: root_id,
1428 source_file: self.root_path.clone(),
1429 toml_path: String::new(),
1430 display_name: "apimock.toml".to_owned(),
1431 kind: NodeKind::RootSetting,
1432 validation: NodeValidation::ok(),
1433 });
1434 }
1435
1436 if let Some(fb_id) = self.ids.id_for(NodeAddress::FallbackRespondDir) {
1437 nodes.push(ConfigNodeView {
1438 id: fb_id,
1439 source_file: self.root_path.clone(),
1440 toml_path: "service.fallback_respond_dir".to_owned(),
1441 display_name: self.config.service.fallback_respond_dir.clone(),
1442 kind: NodeKind::FileNode,
1443 validation: NodeValidation::ok(),
1444 });
1445 }
1446
1447 Some(ConfigFileView {
1448 path: self.root_path.clone(),
1449 display_name: file_basename(&self.root_path),
1450 kind: ConfigFileKind::Root,
1451 nodes,
1452 })
1453 }
1454
1455 fn rule_set_file_view(&self, rs_idx: usize, rule_set: &RuleSet) -> ConfigFileView {
1456 let file_path = PathBuf::from(rule_set.file_path.as_str());
1457 let mut nodes: Vec<ConfigNodeView> = Vec::new();
1458
1459 if let Some(rs_id) = self
1461 .ids
1462 .id_for(NodeAddress::RuleSet { rule_set: rs_idx })
1463 {
1464 nodes.push(ConfigNodeView {
1465 id: rs_id,
1466 source_file: file_path.clone(),
1467 toml_path: String::new(),
1468 display_name: file_basename(&file_path),
1469 kind: NodeKind::RuleSet,
1470 validation: NodeValidation::ok(),
1471 });
1472 }
1473
1474 for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
1476 if let Some(rule_id) = self.ids.id_for(NodeAddress::Rule {
1477 rule_set: rs_idx,
1478 rule: rule_idx,
1479 }) {
1480 let url_path_label = rule
1481 .when
1482 .request
1483 .url_path
1484 .as_ref()
1485 .map(|u| u.value.as_str())
1486 .unwrap_or_default();
1487 let display = if url_path_label.is_empty() {
1488 format!("Rule #{}", rule_idx + 1)
1489 } else {
1490 url_path_label.to_owned()
1491 };
1492 nodes.push(ConfigNodeView {
1493 id: rule_id,
1494 source_file: file_path.clone(),
1495 toml_path: format!("rules[{}]", rule_idx),
1496 display_name: display,
1497 kind: NodeKind::Rule,
1498 validation: NodeValidation::ok(),
1499 });
1500 }
1501
1502 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
1503 rule_set: rs_idx,
1504 rule: rule_idx,
1505 }) {
1506 nodes.push(ConfigNodeView {
1507 id: resp_id,
1508 source_file: file_path.clone(),
1509 toml_path: format!("rules[{}].respond", rule_idx),
1510 display_name: summarise_respond(&rule.respond),
1511 kind: NodeKind::Respond,
1512 validation: respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx),
1513 });
1514 }
1515 }
1516
1517 ConfigFileView {
1518 path: file_path.clone(),
1519 display_name: file_basename(&file_path),
1520 kind: ConfigFileKind::RuleSet,
1521 nodes,
1522 }
1523 }
1524
1525 fn resolve_relative(&self, rel: &str) -> PathBuf {
1526 match self.config.current_dir_to_parent_dir_relative_path() {
1527 Ok(dir) => Path::new(&dir).join(rel),
1528 Err(_) => PathBuf::from(rel),
1529 }
1530 }
1531
1532 pub fn config(&self) -> &Config {
1537 &self.config
1538 }
1539
1540 pub fn root_path(&self) -> &Path {
1542 &self.root_path
1543 }
1544
1545 pub fn list_directory(&self, path: &Path) -> Vec<apimock_routing::view::FileNodeView> {
1567 apimock_routing::view::build::list_directory(path)
1568 }
1569}
1570
1571fn summarise_respond(respond: &apimock_routing::Respond) -> String {
1573 if let Some(p) = respond.file_path.as_ref() {
1574 return format!("file: {}", p);
1575 }
1576 if let Some(t) = respond.text.as_ref() {
1577 const LIMIT: usize = 40;
1578 if t.chars().count() > LIMIT {
1579 let truncated: String = t.chars().take(LIMIT).collect();
1580 return format!("text: {}…", truncated);
1581 }
1582 return format!("text: {}", t);
1583 }
1584 if let Some(s) = respond.status.as_ref() {
1585 return format!("status: {}", s);
1586 }
1587 "(empty)".to_owned()
1588}
1589
1590fn respond_node_validation(
1591 respond: &apimock_routing::Respond,
1592 rule_set: &RuleSet,
1593 rule_idx: usize,
1594 rs_idx: usize,
1595) -> NodeValidation {
1596 let mut issues: Vec<ValidationIssue> = Vec::new();
1600
1601 let any = respond.file_path.is_some() || respond.text.is_some() || respond.status.is_some();
1602 if !any {
1603 issues.push(ValidationIssue {
1604 severity: Severity::Error,
1605 message: "response requires at least one of file_path, text, or status".to_owned(),
1606 });
1607 }
1608 if respond.file_path.is_some() && respond.text.is_some() {
1609 issues.push(ValidationIssue {
1610 severity: Severity::Error,
1611 message: "file_path and text cannot both be set".to_owned(),
1612 });
1613 }
1614 if respond.file_path.is_some() && respond.status.is_some() {
1615 issues.push(ValidationIssue {
1616 severity: Severity::Error,
1617 message: "status cannot be combined with file_path (only with text)".to_owned(),
1618 });
1619 }
1620
1621 if let Some(file_path) = respond.file_path.as_ref() {
1626 let dir_prefix = rule_set.dir_prefix();
1627 let p = Path::new(dir_prefix.as_str()).join(file_path);
1628 if !p.exists() {
1629 issues.push(ValidationIssue {
1630 severity: Severity::Error,
1631 message: format!(
1632 "file not found: {} (rule #{} in rule set #{})",
1633 p.to_string_lossy(),
1634 rule_idx + 1,
1635 rs_idx + 1,
1636 ),
1637 });
1638 }
1639 }
1640
1641 NodeValidation {
1642 ok: issues.is_empty(),
1643 issues,
1644 }
1645}
1646
1647fn file_basename(path: &Path) -> String {
1648 path.file_name()
1649 .map(|n| n.to_string_lossy().into_owned())
1650 .unwrap_or_else(|| path.to_string_lossy().into_owned())
1651}
1652
1653fn rule_to_string(rule: &apimock_routing::Rule) -> String {
1658 let table = crate::toml_writer::rule_table(rule);
1659 toml::to_string_pretty(&toml::Value::Table(table)).unwrap_or_default()
1660}
1661
1662fn atomic_write(path: &Path, text: &str) -> Result<(), SaveError> {
1686 let parent = path
1687 .parent()
1688 .filter(|p| !p.as_os_str().is_empty())
1689 .map(Path::to_path_buf)
1690 .unwrap_or_else(|| PathBuf::from("."));
1691
1692 let mut tmp =
1693 tempfile::NamedTempFile::new_in(&parent).map_err(|e| SaveError::Write {
1694 path: path.to_path_buf(),
1695 source: e,
1696 })?;
1697
1698 use std::io::Write;
1699 tmp.write_all(text.as_bytes())
1700 .map_err(|e| SaveError::Write {
1701 path: path.to_path_buf(),
1702 source: e,
1703 })?;
1704 tmp.flush().map_err(|e| SaveError::Write {
1705 path: path.to_path_buf(),
1706 source: e,
1707 })?;
1708
1709 tmp.persist(path).map_err(|persist_err| SaveError::Write {
1710 path: path.to_path_buf(),
1711 source: persist_err.error,
1712 })?;
1713 Ok(())
1714}
1715
1716fn build_rule_from_payload(
1719 payload: crate::view::RulePayload,
1720 rule_set: &apimock_routing::RuleSet,
1721 rs_idx: usize,
1722) -> Result<apimock_routing::Rule, ApplyError> {
1723 use apimock_routing::rule_set::rule::Rule;
1724 use apimock_routing::rule_set::rule::when::When;
1725 use apimock_routing::rule_set::rule::when::request::{
1726 Request, http_method::HttpMethod, url_path::UrlPathConfig,
1727 };
1728
1729 let url_path_config = payload.url_path.as_ref().map(|s| UrlPathConfig::Simple(s.clone()));
1735
1736 let http_method = match payload.method.as_deref() {
1737 Some("GET") | Some("get") => Some(HttpMethod::Get),
1738 Some("POST") | Some("post") => Some(HttpMethod::Post),
1739 Some("PUT") | Some("put") => Some(HttpMethod::Put),
1740 Some("DELETE") | Some("delete") => Some(HttpMethod::Delete),
1741 Some(other) => {
1742 return Err(ApplyError::InvalidPayload {
1743 reason: format!(
1744 "unsupported HTTP method `{}` — supported: GET, POST, PUT, DELETE",
1745 other
1746 ),
1747 });
1748 }
1749 None => None,
1750 };
1751
1752 let request = Request {
1753 url_path_config,
1754 url_path: None, http_method,
1756 headers: None,
1757 body: None,
1758 };
1759
1760 let rule = Rule {
1761 when: When { request },
1762 respond: build_respond_from_payload(payload.respond),
1763 };
1764
1765 Ok(rule.compute_derived_fields(rule_set, rule_set.rules.len(), rs_idx))
1774}
1775
1776fn build_respond_from_payload(payload: crate::view::RespondPayload) -> apimock_routing::Respond {
1777 apimock_routing::Respond {
1778 file_path: payload.file_path,
1779 csv_records_key: None,
1780 text: payload.text,
1781 status: payload.status,
1782 status_code: None, headers: None,
1784 delay_response_milliseconds: payload.delay_milliseconds,
1785 }
1786}
1787
1788fn value_as_string(value: &EditValue) -> Result<String, ApplyError> {
1789 match value {
1790 EditValue::String(s) => Ok(s.clone()),
1791 EditValue::Enum(s) => Ok(s.clone()),
1792 other => Err(ApplyError::InvalidPayload {
1793 reason: format!("expected a string, got {:?}", other),
1794 }),
1795 }
1796}
1797
1798fn value_as_integer(value: &EditValue) -> Result<i64, ApplyError> {
1799 match value {
1800 EditValue::Integer(n) => Ok(*n),
1801 other => Err(ApplyError::InvalidPayload {
1802 reason: format!("expected an integer, got {:?}", other),
1803 }),
1804 }
1805}
1806
1807fn internal_path_err(err: ConfigError) -> ApplyError {
1813 ApplyError::InvalidPayload {
1814 reason: format!("internal path resolution failed: {}", err),
1815 }
1816}
1817
1818fn resolve_root(root: &Path) -> Result<PathBuf, WorkspaceError> {
1819 if root.is_file() {
1820 return Ok(root.to_path_buf());
1821 }
1822 if root.is_dir() {
1823 let candidate = root.join("apimock.toml");
1824 if candidate.is_file() {
1825 return Ok(candidate);
1826 }
1827 return Err(WorkspaceError::InvalidRoot {
1828 path: root.to_path_buf(),
1829 reason: "directory does not contain apimock.toml".to_owned(),
1830 });
1831 }
1832 Err(WorkspaceError::InvalidRoot {
1833 path: root.to_path_buf(),
1834 reason: "path does not exist".to_owned(),
1835 })
1836}
1837
1838#[allow(dead_code)]
1842fn routing_to_config(err: RoutingError) -> ConfigError {
1843 ConfigError::from(err)
1844}
1845
1846#[cfg(test)]
1847mod tests;