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 routes = apimock_routing::view::RouteCatalogSnapshot::empty();
297
298 WorkspaceSnapshot {
299 files,
300 routes,
301 diagnostics: self.diagnostics.clone(),
302 }
303 }
304
305 pub fn apply(&mut self, cmd: EditCommand) -> Result<ApplyResult, ApplyError> {
325 let (changed_nodes, requires_reload) = match cmd {
326 EditCommand::AddRuleSet { path } => {
327 let ids = self.cmd_add_rule_set(path)?;
328 (ids, true)
329 }
330 EditCommand::RemoveRuleSet { id } => {
331 let ids = self.cmd_remove_rule_set(id)?;
332 (ids, true)
333 }
334 EditCommand::AddRule { parent, rule } => {
335 let ids = self.cmd_add_rule(parent, rule)?;
336 (ids, true)
337 }
338 EditCommand::UpdateRule { id, rule } => {
339 let ids = self.cmd_update_rule(id, rule)?;
340 (ids, true)
341 }
342 EditCommand::DeleteRule { id } => {
343 let ids = self.cmd_delete_rule(id)?;
344 (ids, true)
345 }
346 EditCommand::MoveRule { id, new_index } => {
347 let ids = self.cmd_move_rule(id, new_index)?;
348 (ids, true)
349 }
350 EditCommand::UpdateRespond { id, respond } => {
351 let ids = self.cmd_update_respond(id, respond)?;
352 (ids, true)
353 }
354 EditCommand::UpdateRootSetting { key, value } => {
355 let ids = self.cmd_update_root_setting(key, value)?;
356 (ids, true)
362 }
363 };
364
365 let diagnostics = self.collect_diagnostics();
370
371 Ok(ApplyResult {
372 changed_nodes,
373 diagnostics,
374 requires_reload,
375 })
376 }
377
378 fn cmd_add_rule_set(&mut self, path: String) -> Result<Vec<NodeId>, ApplyError> {
381 let relative_dir = self.config_relative_dir().map_err(internal_path_err)?;
384 let joined = Path::new(&relative_dir).join(&path);
385 let path_str = joined.to_str().ok_or_else(|| ApplyError::InvalidPayload {
386 reason: format!(
387 "path contains non-UTF-8 bytes: {}",
388 joined.to_string_lossy()
389 ),
390 })?;
391
392 let next_idx = self.config.service.rule_sets.len();
393 let new_rule_set = RuleSet::new(path_str, relative_dir.as_str(), next_idx)
394 .map_err(|e| ApplyError::InvalidPayload {
395 reason: format!("failed to load rule set `{}`: {}", path, e),
396 })?;
397
398 let file_paths = self
401 .config
402 .service
403 .rule_sets_file_paths
404 .get_or_insert_with(Vec::new);
405 file_paths.push(path.clone());
406
407 let new_len = self.config.service.rule_sets.len() + 1;
408 self.config.service.rule_sets.push(new_rule_set);
409
410 let rs_addr = NodeAddress::RuleSet {
412 rule_set: next_idx,
413 };
414 let rs_id = self.ids.insert(rs_addr);
415 let mut changed = vec![rs_id];
416 let new_rs = &self.config.service.rule_sets[next_idx];
417 for rule_idx in 0..new_rs.rules.len() {
418 let r_id = self.ids.insert(NodeAddress::Rule {
419 rule_set: next_idx,
420 rule: rule_idx,
421 });
422 let resp_id = self.ids.insert(NodeAddress::Respond {
423 rule_set: next_idx,
424 rule: rule_idx,
425 });
426 changed.push(r_id);
427 changed.push(resp_id);
428 }
429 debug_assert_eq!(new_len, self.config.service.rule_sets.len());
432
433 Ok(changed)
434 }
435
436 fn cmd_remove_rule_set(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
437 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
438 let NodeAddress::RuleSet { rule_set: idx } = addr else {
439 return Err(ApplyError::WrongNodeKind {
440 id,
441 reason: "expected a rule set id".to_owned(),
442 });
443 };
444
445 let len = self.config.service.rule_sets.len();
446 if idx >= len {
447 return Err(ApplyError::InvalidPayload {
448 reason: format!("rule set index {} out of range (len={})", idx, len),
449 });
450 }
451
452 let mut changed: Vec<NodeId> = Vec::new();
455 changed.push(id);
457 if let Some(removed_rs) = self.config.service.rule_sets.get(idx) {
458 for rule_idx in 0..removed_rs.rules.len() {
459 if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
460 rule_set: idx,
461 rule: rule_idx,
462 }) {
463 changed.push(r_id);
464 }
465 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
466 rule_set: idx,
467 rule: rule_idx,
468 }) {
469 changed.push(resp_id);
470 }
471 }
472 }
473
474 self.config.service.rule_sets.remove(idx);
476 if let Some(paths) = self.config.service.rule_sets_file_paths.as_mut() {
477 if idx < paths.len() {
478 paths.remove(idx);
479 }
480 }
481
482 self.shift_rule_sets_down(idx);
487
488 for shifted_idx in idx..self.config.service.rule_sets.len() {
492 if let Some(shifted_id) = self
493 .ids
494 .id_for(NodeAddress::RuleSet {
495 rule_set: shifted_idx,
496 })
497 {
498 if !changed.contains(&shifted_id) {
499 changed.push(shifted_id);
500 }
501 }
502 }
503
504 Ok(changed)
505 }
506
507 fn cmd_add_rule(
508 &mut self,
509 parent: NodeId,
510 rule_payload: crate::view::RulePayload,
511 ) -> Result<Vec<NodeId>, ApplyError> {
512 let addr = self
513 .ids
514 .lookup(parent)
515 .ok_or(ApplyError::UnknownNode { id: parent })?;
516 let NodeAddress::RuleSet { rule_set: rs_idx } = addr else {
517 return Err(ApplyError::WrongNodeKind {
518 id: parent,
519 reason: "expected a rule set id (parent for AddRule must be a rule set)".to_owned(),
520 });
521 };
522
523 let rule_set = self
524 .config
525 .service
526 .rule_sets
527 .get_mut(rs_idx)
528 .ok_or_else(|| ApplyError::InvalidPayload {
529 reason: format!("rule set index {} out of range", rs_idx),
530 })?;
531
532 let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
533 let new_rule_idx = rule_set.rules.len();
534 rule_set.rules.push(new_rule);
535
536 let r_id = self.ids.insert(NodeAddress::Rule {
537 rule_set: rs_idx,
538 rule: new_rule_idx,
539 });
540 let resp_id = self.ids.insert(NodeAddress::Respond {
541 rule_set: rs_idx,
542 rule: new_rule_idx,
543 });
544 Ok(vec![parent, r_id, resp_id])
545 }
546
547 fn cmd_update_rule(
548 &mut self,
549 id: NodeId,
550 rule_payload: crate::view::RulePayload,
551 ) -> Result<Vec<NodeId>, ApplyError> {
552 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
553 let NodeAddress::Rule {
554 rule_set: rs_idx,
555 rule: rule_idx,
556 } = addr
557 else {
558 return Err(ApplyError::WrongNodeKind {
559 id,
560 reason: "expected a rule id".to_owned(),
561 });
562 };
563
564 let rule_set = self
565 .config
566 .service
567 .rule_sets
568 .get_mut(rs_idx)
569 .ok_or_else(|| ApplyError::InvalidPayload {
570 reason: format!("rule set index {} out of range", rs_idx),
571 })?;
572
573 let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
574 *rule_set
575 .rules
576 .get_mut(rule_idx)
577 .ok_or_else(|| ApplyError::InvalidPayload {
578 reason: format!("rule index {} out of range", rule_idx),
579 })? = new_rule;
580
581 let resp_id = self
582 .ids
583 .id_for(NodeAddress::Respond {
584 rule_set: rs_idx,
585 rule: rule_idx,
586 })
587 .unwrap_or_else(NodeId::new);
588 Ok(vec![id, resp_id])
589 }
590
591 fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
592 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
593 let NodeAddress::Rule {
594 rule_set: rs_idx,
595 rule: rule_idx,
596 } = addr
597 else {
598 return Err(ApplyError::WrongNodeKind {
599 id,
600 reason: "expected a rule id".to_owned(),
601 });
602 };
603
604 let rule_set = self
605 .config
606 .service
607 .rule_sets
608 .get_mut(rs_idx)
609 .ok_or_else(|| ApplyError::InvalidPayload {
610 reason: format!("rule set index {} out of range", rs_idx),
611 })?;
612
613 if rule_idx >= rule_set.rules.len() {
614 return Err(ApplyError::InvalidPayload {
615 reason: format!("rule index {} out of range", rule_idx),
616 });
617 }
618
619 let mut changed: Vec<NodeId> = Vec::new();
621 changed.push(id);
622 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
623 rule_set: rs_idx,
624 rule: rule_idx,
625 }) {
626 changed.push(resp_id);
627 }
628
629 rule_set.rules.remove(rule_idx);
630 self.shift_rules_down(rs_idx, rule_idx);
631
632 let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
634 for shifted_idx in rule_idx..new_rule_count {
635 if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
636 rule_set: rs_idx,
637 rule: shifted_idx,
638 }) {
639 if !changed.contains(&r_id) {
640 changed.push(r_id);
641 }
642 }
643 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
644 rule_set: rs_idx,
645 rule: shifted_idx,
646 }) {
647 if !changed.contains(&resp_id) {
648 changed.push(resp_id);
649 }
650 }
651 }
652
653 Ok(changed)
654 }
655
656 fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
657 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
658 let NodeAddress::Rule {
659 rule_set: rs_idx,
660 rule: old_idx,
661 } = addr
662 else {
663 return Err(ApplyError::WrongNodeKind {
664 id,
665 reason: "expected a rule id".to_owned(),
666 });
667 };
668
669 let rule_set = self
670 .config
671 .service
672 .rule_sets
673 .get_mut(rs_idx)
674 .ok_or_else(|| ApplyError::InvalidPayload {
675 reason: format!("rule set index {} out of range", rs_idx),
676 })?;
677
678 if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
679 return Err(ApplyError::InvalidPayload {
680 reason: format!(
681 "move out of bounds: old_idx={}, new_index={}, len={}",
682 old_idx,
683 new_index,
684 rule_set.rules.len()
685 ),
686 });
687 }
688 if old_idx == new_index {
689 return Ok(vec![id]);
690 }
691
692 let rule = rule_set.rules.remove(old_idx);
694 rule_set.rules.insert(new_index, rule);
695
696 self.reorder_rule_ids(rs_idx, old_idx, new_index);
701
702 let lo = old_idx.min(new_index);
705 let hi = old_idx.max(new_index);
706 let mut changed: Vec<NodeId> = Vec::new();
707 for idx in lo..=hi {
708 if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
709 rule_set: rs_idx,
710 rule: idx,
711 }) {
712 changed.push(r_id);
713 }
714 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
715 rule_set: rs_idx,
716 rule: idx,
717 }) {
718 changed.push(resp_id);
719 }
720 }
721 Ok(changed)
722 }
723
724 fn cmd_update_respond(
725 &mut self,
726 id: NodeId,
727 respond: crate::view::RespondPayload,
728 ) -> Result<Vec<NodeId>, ApplyError> {
729 let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
730 let NodeAddress::Respond {
731 rule_set: rs_idx,
732 rule: rule_idx,
733 } = addr
734 else {
735 return Err(ApplyError::WrongNodeKind {
736 id,
737 reason: "expected a respond id".to_owned(),
738 });
739 };
740
741 let rule = self
742 .config
743 .service
744 .rule_sets
745 .get_mut(rs_idx)
746 .and_then(|rs| rs.rules.get_mut(rule_idx))
747 .ok_or_else(|| ApplyError::InvalidPayload {
748 reason: format!(
749 "rule at rule_set={}, rule={} not found",
750 rs_idx, rule_idx
751 ),
752 })?;
753
754 rule.respond = build_respond_from_payload(respond);
755
756 let rule_set = &self.config.service.rule_sets[rs_idx];
759 let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
760 self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
761
762 Ok(vec![id])
763 }
764
765 fn cmd_update_root_setting(
766 &mut self,
767 key: crate::view::RootSettingKey,
768 value: EditValue,
769 ) -> Result<Vec<NodeId>, ApplyError> {
770 use crate::view::RootSettingKey::*;
771
772 match key {
773 ListenerIpAddress => {
774 let s = value_as_string(&value)?;
775 let listener = self.config.listener.get_or_insert_with(Default::default);
776 listener.ip_address = s;
777 }
778 ListenerPort => {
779 let n = value_as_integer(&value)?;
780 if !(0..=u16::MAX as i64).contains(&n) {
781 return Err(ApplyError::InvalidPayload {
782 reason: format!("port {} not in 0..=65535", n),
783 });
784 }
785 let listener = self.config.listener.get_or_insert_with(Default::default);
786 listener.port = n as u16;
787 }
788 ServiceFallbackRespondDir => {
789 let s = value_as_string(&value)?;
790 self.config.service.fallback_respond_dir = s;
791 }
792 ServiceStrategy => {
793 let s = value_as_string(&value)?;
794 match s.as_str() {
798 "first_match" => {
799 self.config.service.strategy =
800 Some(apimock_routing::Strategy::FirstMatch);
801 }
802 other => {
803 return Err(ApplyError::InvalidPayload {
804 reason: format!("unknown strategy: {}", other),
805 });
806 }
807 }
808 }
809 }
810
811 let id = self
813 .ids
814 .id_for(NodeAddress::Root)
815 .expect("root id seeded at load");
816 Ok(vec![id])
817 }
818
819 fn shift_rule_sets_down(&mut self, removed_idx: usize) {
824 let new_rs_count = self.config.service.rule_sets.len();
830
831 let mut stale: Vec<NodeAddress> = Vec::new();
835 stale.push(NodeAddress::RuleSet {
836 rule_set: removed_idx,
837 });
838 for old_idx in removed_idx..new_rs_count + 1 {
840 stale.push(NodeAddress::RuleSet { rule_set: old_idx });
841 }
844
845 let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
849 for (&addr, &id) in self.ids.address_to_id.iter() {
850 match addr {
851 NodeAddress::RuleSet { rule_set } if rule_set >= removed_idx => {
852 to_migrate.push((id, addr));
853 }
854 NodeAddress::Rule { rule_set, .. } if rule_set >= removed_idx => {
855 to_migrate.push((id, addr));
856 }
857 NodeAddress::Respond { rule_set, .. } if rule_set >= removed_idx => {
858 to_migrate.push((id, addr));
859 }
860 _ => {}
861 }
862 }
863
864 for (id, addr) in &to_migrate {
865 self.ids.address_to_id.remove(addr);
866 self.ids.id_to_address.remove(id);
867 }
868
869 for (id, addr) in to_migrate {
872 let new_addr = match addr {
873 NodeAddress::RuleSet { rule_set } => {
874 if rule_set == removed_idx {
875 continue; }
877 NodeAddress::RuleSet {
878 rule_set: rule_set - 1,
879 }
880 }
881 NodeAddress::Rule { rule_set, rule } => {
882 if rule_set == removed_idx {
883 continue;
884 }
885 NodeAddress::Rule {
886 rule_set: rule_set - 1,
887 rule,
888 }
889 }
890 NodeAddress::Respond { rule_set, rule } => {
891 if rule_set == removed_idx {
892 continue;
893 }
894 NodeAddress::Respond {
895 rule_set: rule_set - 1,
896 rule,
897 }
898 }
899 other => other,
900 };
901 self.ids.id_to_address.insert(id, new_addr);
902 self.ids.address_to_id.insert(new_addr, id);
903 }
904 }
905
906 fn shift_rules_down(&mut self, rule_set_idx: usize, removed_rule_idx: usize) {
909 let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
910 for (&addr, &id) in self.ids.address_to_id.iter() {
911 match addr {
912 NodeAddress::Rule { rule_set, rule }
913 if rule_set == rule_set_idx && rule >= removed_rule_idx =>
914 {
915 to_migrate.push((id, addr));
916 }
917 NodeAddress::Respond { rule_set, rule }
918 if rule_set == rule_set_idx && rule >= removed_rule_idx =>
919 {
920 to_migrate.push((id, addr));
921 }
922 _ => {}
923 }
924 }
925
926 for (id, addr) in &to_migrate {
927 self.ids.address_to_id.remove(addr);
928 self.ids.id_to_address.remove(id);
929 }
930
931 for (id, addr) in to_migrate {
932 let new_addr = match addr {
933 NodeAddress::Rule { rule_set, rule } => {
934 if rule == removed_rule_idx {
935 continue;
936 }
937 NodeAddress::Rule {
938 rule_set,
939 rule: rule - 1,
940 }
941 }
942 NodeAddress::Respond { rule_set, rule } => {
943 if rule == removed_rule_idx {
944 continue;
945 }
946 NodeAddress::Respond {
947 rule_set,
948 rule: rule - 1,
949 }
950 }
951 other => other,
952 };
953 self.ids.id_to_address.insert(id, new_addr);
954 self.ids.address_to_id.insert(new_addr, id);
955 }
956 }
957
958 fn reorder_rule_ids(&mut self, rule_set_idx: usize, old_idx: usize, new_idx: usize) {
961 let rule_count = self.config.service.rule_sets[rule_set_idx].rules.len();
963 let mut rule_ids: Vec<Option<NodeId>> = (0..rule_count)
964 .map(|r| {
965 self.ids.id_for(NodeAddress::Rule {
966 rule_set: rule_set_idx,
967 rule: r,
968 })
969 })
970 .collect();
971 let mut resp_ids: Vec<Option<NodeId>> = (0..rule_count)
972 .map(|r| {
973 self.ids.id_for(NodeAddress::Respond {
974 rule_set: rule_set_idx,
975 rule: r,
976 })
977 })
978 .collect();
979
980 let moving_r = rule_ids.remove(old_idx);
986 rule_ids.insert(new_idx, moving_r);
987 let moving_resp = resp_ids.remove(old_idx);
988 resp_ids.insert(new_idx, moving_resp);
989
990 for r in 0..rule_count {
992 let rule_addr = NodeAddress::Rule {
993 rule_set: rule_set_idx,
994 rule: r,
995 };
996 let resp_addr = NodeAddress::Respond {
997 rule_set: rule_set_idx,
998 rule: r,
999 };
1000 if let Some(prev_id) = self.ids.address_to_id.remove(&rule_addr) {
1001 self.ids.id_to_address.remove(&prev_id);
1002 }
1003 if let Some(prev_id) = self.ids.address_to_id.remove(&resp_addr) {
1004 self.ids.id_to_address.remove(&prev_id);
1005 }
1006 }
1007 for (r, id_opt) in rule_ids.into_iter().enumerate() {
1008 let addr = NodeAddress::Rule {
1009 rule_set: rule_set_idx,
1010 rule: r,
1011 };
1012 let id = id_opt.unwrap_or_else(NodeId::new);
1013 self.ids.id_to_address.insert(id, addr);
1014 self.ids.address_to_id.insert(addr, id);
1015 }
1016 for (r, id_opt) in resp_ids.into_iter().enumerate() {
1017 let addr = NodeAddress::Respond {
1018 rule_set: rule_set_idx,
1019 rule: r,
1020 };
1021 let id = id_opt.unwrap_or_else(NodeId::new);
1022 self.ids.id_to_address.insert(id, addr);
1023 self.ids.address_to_id.insert(addr, id);
1024 }
1025 }
1026
1027 fn config_relative_dir(&self) -> Result<String, ConfigError> {
1028 self.config.current_dir_to_parent_dir_relative_path()
1029 }
1030
1031 fn collect_diagnostics(&self) -> Vec<Diagnostic> {
1035 let mut out: Vec<Diagnostic> = Vec::new();
1036 for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
1037 for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
1038 let nv = respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx);
1039 if nv.ok {
1040 continue;
1041 }
1042 let resp_id = self.ids.id_for(NodeAddress::Respond {
1043 rule_set: rs_idx,
1044 rule: rule_idx,
1045 });
1046 for issue in nv.issues {
1047 out.push(Diagnostic {
1048 node_id: resp_id,
1049 file: Some(PathBuf::from(rule_set.file_path.as_str())),
1050 severity: issue.severity,
1051 message: issue.message,
1052 });
1053 }
1054 }
1055 }
1056
1057 if !Path::new(self.config.service.fallback_respond_dir.as_str()).exists() {
1059 out.push(Diagnostic {
1060 node_id: self.ids.id_for(NodeAddress::FallbackRespondDir),
1061 file: Some(self.root_path.clone()),
1062 severity: Severity::Error,
1063 message: format!(
1064 "fallback_respond_dir does not exist: {}",
1065 self.config.service.fallback_respond_dir
1066 ),
1067 });
1068 }
1069
1070 out
1071 }
1072
1073 pub fn validate(&self) -> ValidationReport {
1081 let diagnostics = self.collect_diagnostics();
1082 let is_valid = !diagnostics
1083 .iter()
1084 .any(|d| matches!(d.severity, Severity::Error));
1085 ValidationReport {
1086 diagnostics,
1087 is_valid,
1088 }
1089 }
1090
1091 pub fn save(&mut self) -> Result<SaveResult, SaveError> {
1122 let new_root_toml = crate::toml_writer::render_apimock_toml(&self.config);
1124
1125 let mut rule_set_renders: Vec<(PathBuf, String)> = Vec::new();
1126 for rule_set in self.config.service.rule_sets.iter() {
1127 let path = PathBuf::from(rule_set.file_path.as_str());
1128 let text = crate::toml_writer::render_rule_set_toml(rule_set);
1129 rule_set_renders.push((path, text));
1130 }
1131
1132 let mut to_write: Vec<(PathBuf, String)> = Vec::new();
1134
1135 let baseline_root = self.baseline_files.get(&self.root_path);
1136 if baseline_root.map(String::as_str) != Some(new_root_toml.as_str()) {
1137 to_write.push((self.root_path.clone(), new_root_toml.clone()));
1138 }
1139 for (path, text) in rule_set_renders.iter() {
1140 let baseline = self.baseline_files.get(path);
1141 if baseline.map(String::as_str) != Some(text.as_str()) {
1142 to_write.push((path.clone(), text.clone()));
1143 }
1144 }
1145
1146 let mut written: Vec<PathBuf> = Vec::with_capacity(to_write.len());
1148 for (path, text) in &to_write {
1149 atomic_write(path, text)?;
1150 written.push(path.clone());
1151 }
1152
1153 let diff_summary = self.compute_diff_summary();
1158
1159 for (path, text) in to_write.into_iter() {
1161 self.baseline_files.insert(path, text);
1162 }
1163
1164 let listener_changed = written.contains(&self.root_path);
1169 let requires_reload = listener_changed || !written.is_empty();
1170
1171 Ok(SaveResult {
1172 changed_files: written,
1173 diff_summary,
1174 requires_reload,
1175 })
1176 }
1177
1178 fn compute_diff_summary(&self) -> Vec<crate::view::DiffItem> {
1196 use crate::view::{DiffItem, DiffKind};
1197
1198 let mut out = Vec::new();
1199
1200 for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
1202 let path = PathBuf::from(rule_set.file_path.as_str());
1203 let rendered = crate::toml_writer::render_rule_set_toml(rule_set);
1204 let baseline_matches = self
1205 .baseline_files
1206 .get(&path)
1207 .map(|s| s.as_str() == rendered.as_str())
1208 .unwrap_or(false);
1209 if !baseline_matches {
1210 if let Some(rs_id) = self.ids.id_for(NodeAddress::RuleSet { rule_set: rs_idx }) {
1211 let kind = if self.baseline_files.contains_key(&path) {
1212 DiffKind::Updated
1213 } else {
1214 DiffKind::Added
1215 };
1216 out.push(DiffItem {
1217 kind,
1218 target: rs_id,
1219 summary: format!(
1220 "rule set #{} ({}): rules={}",
1221 rs_idx + 1,
1222 file_basename(&path),
1223 rule_set.rules.len(),
1224 ),
1225 });
1226 }
1227 }
1228 }
1229
1230 let root_rendered = crate::toml_writer::render_apimock_toml(&self.config);
1232 let root_baseline_matches = self
1233 .baseline_files
1234 .get(&self.root_path)
1235 .map(|s| s.as_str() == root_rendered.as_str())
1236 .unwrap_or(false);
1237 if !root_baseline_matches {
1238 if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
1239 out.push(DiffItem {
1240 kind: DiffKind::Updated,
1241 target: root_id,
1242 summary: format!(
1243 "{}: listener / log / service",
1244 file_basename(&self.root_path)
1245 ),
1246 });
1247 }
1248 }
1249
1250 out
1251 }
1252
1253 pub fn has_unsaved_changes(&self) -> bool {
1261 let root_text = crate::toml_writer::render_apimock_toml(&self.config);
1262 if self
1263 .baseline_files
1264 .get(&self.root_path)
1265 .map(|s| s.as_str())
1266 != Some(root_text.as_str())
1267 {
1268 return true;
1269 }
1270 for rule_set in self.config.service.rule_sets.iter() {
1271 let path = PathBuf::from(rule_set.file_path.as_str());
1272 let text = crate::toml_writer::render_rule_set_toml(rule_set);
1273 if self
1274 .baseline_files
1275 .get(&path)
1276 .map(|s| s.as_str())
1277 != Some(text.as_str())
1278 {
1279 return true;
1280 }
1281 }
1282 false
1283 }
1284
1285 fn root_file_nodes(&self) -> Option<ConfigFileView> {
1287 let mut nodes = Vec::new();
1288
1289 if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
1290 nodes.push(ConfigNodeView {
1291 id: root_id,
1292 source_file: self.root_path.clone(),
1293 toml_path: String::new(),
1294 display_name: "apimock.toml".to_owned(),
1295 kind: NodeKind::RootSetting,
1296 validation: NodeValidation::ok(),
1297 });
1298 }
1299
1300 if let Some(fb_id) = self.ids.id_for(NodeAddress::FallbackRespondDir) {
1301 nodes.push(ConfigNodeView {
1302 id: fb_id,
1303 source_file: self.root_path.clone(),
1304 toml_path: "service.fallback_respond_dir".to_owned(),
1305 display_name: self.config.service.fallback_respond_dir.clone(),
1306 kind: NodeKind::FileNode,
1307 validation: NodeValidation::ok(),
1308 });
1309 }
1310
1311 Some(ConfigFileView {
1312 path: self.root_path.clone(),
1313 display_name: file_basename(&self.root_path),
1314 kind: ConfigFileKind::Root,
1315 nodes,
1316 })
1317 }
1318
1319 fn rule_set_file_view(&self, rs_idx: usize, rule_set: &RuleSet) -> ConfigFileView {
1320 let file_path = PathBuf::from(rule_set.file_path.as_str());
1321 let mut nodes: Vec<ConfigNodeView> = Vec::new();
1322
1323 if let Some(rs_id) = self
1325 .ids
1326 .id_for(NodeAddress::RuleSet { rule_set: rs_idx })
1327 {
1328 nodes.push(ConfigNodeView {
1329 id: rs_id,
1330 source_file: file_path.clone(),
1331 toml_path: String::new(),
1332 display_name: file_basename(&file_path),
1333 kind: NodeKind::RuleSet,
1334 validation: NodeValidation::ok(),
1335 });
1336 }
1337
1338 for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
1340 if let Some(rule_id) = self.ids.id_for(NodeAddress::Rule {
1341 rule_set: rs_idx,
1342 rule: rule_idx,
1343 }) {
1344 let url_path_label = rule
1345 .when
1346 .request
1347 .url_path
1348 .as_ref()
1349 .map(|u| u.value.as_str())
1350 .unwrap_or_default();
1351 let display = if url_path_label.is_empty() {
1352 format!("Rule #{}", rule_idx + 1)
1353 } else {
1354 url_path_label.to_owned()
1355 };
1356 nodes.push(ConfigNodeView {
1357 id: rule_id,
1358 source_file: file_path.clone(),
1359 toml_path: format!("rules[{}]", rule_idx),
1360 display_name: display,
1361 kind: NodeKind::Rule,
1362 validation: NodeValidation::ok(),
1363 });
1364 }
1365
1366 if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
1367 rule_set: rs_idx,
1368 rule: rule_idx,
1369 }) {
1370 nodes.push(ConfigNodeView {
1371 id: resp_id,
1372 source_file: file_path.clone(),
1373 toml_path: format!("rules[{}].respond", rule_idx),
1374 display_name: summarise_respond(&rule.respond),
1375 kind: NodeKind::Respond,
1376 validation: respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx),
1377 });
1378 }
1379 }
1380
1381 ConfigFileView {
1382 path: file_path.clone(),
1383 display_name: file_basename(&file_path),
1384 kind: ConfigFileKind::RuleSet,
1385 nodes,
1386 }
1387 }
1388
1389 fn resolve_relative(&self, rel: &str) -> PathBuf {
1390 match self.config.current_dir_to_parent_dir_relative_path() {
1391 Ok(dir) => Path::new(&dir).join(rel),
1392 Err(_) => PathBuf::from(rel),
1393 }
1394 }
1395
1396 pub fn config(&self) -> &Config {
1401 &self.config
1402 }
1403
1404 pub fn root_path(&self) -> &Path {
1406 &self.root_path
1407 }
1408}
1409
1410fn summarise_respond(respond: &apimock_routing::Respond) -> String {
1412 if let Some(p) = respond.file_path.as_ref() {
1413 return format!("file: {}", p);
1414 }
1415 if let Some(t) = respond.text.as_ref() {
1416 const LIMIT: usize = 40;
1417 if t.chars().count() > LIMIT {
1418 let truncated: String = t.chars().take(LIMIT).collect();
1419 return format!("text: {}…", truncated);
1420 }
1421 return format!("text: {}", t);
1422 }
1423 if let Some(s) = respond.status.as_ref() {
1424 return format!("status: {}", s);
1425 }
1426 "(empty)".to_owned()
1427}
1428
1429fn respond_node_validation(
1430 respond: &apimock_routing::Respond,
1431 rule_set: &RuleSet,
1432 rule_idx: usize,
1433 rs_idx: usize,
1434) -> NodeValidation {
1435 let mut issues: Vec<ValidationIssue> = Vec::new();
1439
1440 let any = respond.file_path.is_some() || respond.text.is_some() || respond.status.is_some();
1441 if !any {
1442 issues.push(ValidationIssue {
1443 severity: Severity::Error,
1444 message: "response requires at least one of file_path, text, or status".to_owned(),
1445 });
1446 }
1447 if respond.file_path.is_some() && respond.text.is_some() {
1448 issues.push(ValidationIssue {
1449 severity: Severity::Error,
1450 message: "file_path and text cannot both be set".to_owned(),
1451 });
1452 }
1453 if respond.file_path.is_some() && respond.status.is_some() {
1454 issues.push(ValidationIssue {
1455 severity: Severity::Error,
1456 message: "status cannot be combined with file_path (only with text)".to_owned(),
1457 });
1458 }
1459
1460 if let Some(file_path) = respond.file_path.as_ref() {
1465 let dir_prefix = rule_set.dir_prefix();
1466 let p = Path::new(dir_prefix.as_str()).join(file_path);
1467 if !p.exists() {
1468 issues.push(ValidationIssue {
1469 severity: Severity::Error,
1470 message: format!(
1471 "file not found: {} (rule #{} in rule set #{})",
1472 p.to_string_lossy(),
1473 rule_idx + 1,
1474 rs_idx + 1,
1475 ),
1476 });
1477 }
1478 }
1479
1480 NodeValidation {
1481 ok: issues.is_empty(),
1482 issues,
1483 }
1484}
1485
1486fn file_basename(path: &Path) -> String {
1487 path.file_name()
1488 .map(|n| n.to_string_lossy().into_owned())
1489 .unwrap_or_else(|| path.to_string_lossy().into_owned())
1490}
1491
1492fn atomic_write(path: &Path, text: &str) -> Result<(), SaveError> {
1516 let parent = path
1517 .parent()
1518 .filter(|p| !p.as_os_str().is_empty())
1519 .map(Path::to_path_buf)
1520 .unwrap_or_else(|| PathBuf::from("."));
1521
1522 let mut tmp =
1523 tempfile::NamedTempFile::new_in(&parent).map_err(|e| SaveError::Write {
1524 path: path.to_path_buf(),
1525 source: e,
1526 })?;
1527
1528 use std::io::Write;
1529 tmp.write_all(text.as_bytes())
1530 .map_err(|e| SaveError::Write {
1531 path: path.to_path_buf(),
1532 source: e,
1533 })?;
1534 tmp.flush().map_err(|e| SaveError::Write {
1535 path: path.to_path_buf(),
1536 source: e,
1537 })?;
1538
1539 tmp.persist(path).map_err(|persist_err| SaveError::Write {
1540 path: path.to_path_buf(),
1541 source: persist_err.error,
1542 })?;
1543 Ok(())
1544}
1545
1546fn build_rule_from_payload(
1549 payload: crate::view::RulePayload,
1550 rule_set: &apimock_routing::RuleSet,
1551 rs_idx: usize,
1552) -> Result<apimock_routing::Rule, ApplyError> {
1553 use apimock_routing::rule_set::rule::Rule;
1554 use apimock_routing::rule_set::rule::when::When;
1555 use apimock_routing::rule_set::rule::when::request::{
1556 Request, http_method::HttpMethod, url_path::UrlPathConfig,
1557 };
1558
1559 let url_path_config = payload.url_path.as_ref().map(|s| UrlPathConfig::Simple(s.clone()));
1565
1566 let http_method = match payload.method.as_deref() {
1567 Some("GET") | Some("get") => Some(HttpMethod::Get),
1568 Some("POST") | Some("post") => Some(HttpMethod::Post),
1569 Some("PUT") | Some("put") => Some(HttpMethod::Put),
1570 Some("DELETE") | Some("delete") => Some(HttpMethod::Delete),
1571 Some(other) => {
1572 return Err(ApplyError::InvalidPayload {
1573 reason: format!(
1574 "unsupported HTTP method `{}` — supported: GET, POST, PUT, DELETE",
1575 other
1576 ),
1577 });
1578 }
1579 None => None,
1580 };
1581
1582 let request = Request {
1583 url_path_config,
1584 url_path: None, http_method,
1586 headers: None,
1587 body: None,
1588 };
1589
1590 let rule = Rule {
1591 when: When { request },
1592 respond: build_respond_from_payload(payload.respond),
1593 };
1594
1595 Ok(rule.compute_derived_fields(rule_set, rule_set.rules.len(), rs_idx))
1604}
1605
1606fn build_respond_from_payload(payload: crate::view::RespondPayload) -> apimock_routing::Respond {
1607 apimock_routing::Respond {
1608 file_path: payload.file_path,
1609 csv_records_key: None,
1610 text: payload.text,
1611 status: payload.status,
1612 status_code: None, headers: None,
1614 delay_response_milliseconds: payload.delay_milliseconds,
1615 }
1616}
1617
1618fn value_as_string(value: &EditValue) -> Result<String, ApplyError> {
1619 match value {
1620 EditValue::String(s) => Ok(s.clone()),
1621 EditValue::Enum(s) => Ok(s.clone()),
1622 other => Err(ApplyError::InvalidPayload {
1623 reason: format!("expected a string, got {:?}", other),
1624 }),
1625 }
1626}
1627
1628fn value_as_integer(value: &EditValue) -> Result<i64, ApplyError> {
1629 match value {
1630 EditValue::Integer(n) => Ok(*n),
1631 other => Err(ApplyError::InvalidPayload {
1632 reason: format!("expected an integer, got {:?}", other),
1633 }),
1634 }
1635}
1636
1637fn internal_path_err(err: ConfigError) -> ApplyError {
1643 ApplyError::InvalidPayload {
1644 reason: format!("internal path resolution failed: {}", err),
1645 }
1646}
1647
1648fn resolve_root(root: &Path) -> Result<PathBuf, WorkspaceError> {
1649 if root.is_file() {
1650 return Ok(root.to_path_buf());
1651 }
1652 if root.is_dir() {
1653 let candidate = root.join("apimock.toml");
1654 if candidate.is_file() {
1655 return Ok(candidate);
1656 }
1657 return Err(WorkspaceError::InvalidRoot {
1658 path: root.to_path_buf(),
1659 reason: "directory does not contain apimock.toml".to_owned(),
1660 });
1661 }
1662 Err(WorkspaceError::InvalidRoot {
1663 path: root.to_path_buf(),
1664 reason: "path does not exist".to_owned(),
1665 })
1666}
1667
1668#[allow(dead_code)]
1672fn routing_to_config(err: RoutingError) -> ConfigError {
1673 ConfigError::from(err)
1674}
1675
1676#[cfg(test)]
1677mod tests;