1use crate::config::Config;
2use crate::error::SwitcherError;
3use crate::resolver::{sibling_for_target, CurrentMatch};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Mode {
8 Direct,
9 CycleForward,
10 CycleBackward,
11}
12
13pub fn select(
14 current: &CurrentMatch,
15 mode: Mode,
16 target: Option<&str>,
17 config: &Config,
18) -> Result<PathBuf, SwitcherError> {
19 match mode {
20 Mode::Direct => {
21 let target =
22 target.ok_or_else(|| SwitcherError::Input("--to requires a target name".into()))?;
23 if !config.targets.contains_key(target) {
24 return Err(SwitcherError::Input(format!(
25 "unknown target '{target}'. Known targets: {}",
26 config
27 .targets
28 .keys()
29 .cloned()
30 .collect::<Vec<_>>()
31 .join(", ")
32 )));
33 }
34 sibling_for_target(current, target, config).ok_or_else(|| SwitcherError::NoSibling {
35 target: Some(target.to_string()),
36 })
37 }
38 Mode::CycleForward | Mode::CycleBackward => cycle(current, mode, config),
39 }
40}
41
42fn cycle(current: &CurrentMatch, mode: Mode, config: &Config) -> Result<PathBuf, SwitcherError> {
43 let order = &config.cycle.order;
44 let n: i64 = i64::try_from(order.len())
45 .map_err(|_| SwitcherError::Config("cycle.order is too large".into()))?;
46 let dir: i64 = if mode == Mode::CycleForward { 1 } else { -1 };
47 let start_idx = order.iter().position(|t| t == ¤t.target);
48
49 for step in 1..=n {
50 let raw: i64 = match start_idx {
51 Some(idx) => {
52 let idx_i = i64::try_from(idx).expect("position within Vec fits in i64");
53 idx_i + dir * step
54 }
55 None => {
56 if mode == Mode::CycleForward {
57 step - 1
58 } else {
59 n - step
60 }
61 }
62 };
63 let idx_i64: i64 = if config.cycle.wrap {
64 raw.rem_euclid(n)
65 } else if !(0..n).contains(&raw) {
66 continue;
67 } else {
68 raw
69 };
70 let idx = usize::try_from(idx_i64).expect("non-negative after rem_euclid / bounds check");
71 let candidate_target = &order[idx];
72 if !current.target.is_empty() && candidate_target == ¤t.target {
73 continue;
74 }
75 if let Some(path) = sibling_for_target(current, candidate_target, config) {
76 return Ok(path);
77 }
78 if !config.cycle.skip_missing {
79 return Err(SwitcherError::NoSibling {
80 target: Some(candidate_target.clone()),
81 });
82 }
83 }
84 Err(SwitcherError::NoSibling { target: None })
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use crate::resolver::identify_current;
91 use std::fs;
92 use std::path::Path;
93 use tempfile::tempdir;
94
95 fn touch(dir: &Path, name: &str) -> PathBuf {
96 let p = dir.join(name);
97 fs::write(&p, "").unwrap();
98 p
99 }
100
101 #[test]
102 fn cycle_ts_to_html_when_all_exist() {
103 let dir = tempdir().unwrap();
104 let ts = touch(dir.path(), "foo.component.ts");
105 touch(dir.path(), "foo.component.html");
106 touch(dir.path(), "foo.component.scss");
107 touch(dir.path(), "foo.component.spec.ts");
108
109 let cfg = Config::default();
110 let cur = identify_current(&ts, &cfg).unwrap();
111 let next = select(&cur, Mode::CycleForward, None, &cfg).unwrap();
112 assert_eq!(next.file_name().unwrap(), "foo.component.html");
113 }
114
115 #[test]
116 fn cycle_full_loop() {
117 let dir = tempdir().unwrap();
118 let ts = touch(dir.path(), "foo.component.ts");
119 touch(dir.path(), "foo.component.html");
120 touch(dir.path(), "foo.component.scss");
121 touch(dir.path(), "foo.component.spec.ts");
122 let cfg = Config::default();
123
124 let cur = identify_current(&ts, &cfg).unwrap();
125 let html = select(&cur, Mode::CycleForward, None, &cfg).unwrap();
126 let cur2 = identify_current(&html, &cfg).unwrap();
127 let scss = select(&cur2, Mode::CycleForward, None, &cfg).unwrap();
128 let cur3 = identify_current(&scss, &cfg).unwrap();
129 let spec = select(&cur3, Mode::CycleForward, None, &cfg).unwrap();
130 let cur4 = identify_current(&spec, &cfg).unwrap();
131 let back_to_ts = select(&cur4, Mode::CycleForward, None, &cfg).unwrap();
132
133 assert_eq!(html.file_name().unwrap(), "foo.component.html");
134 assert_eq!(scss.file_name().unwrap(), "foo.component.scss");
135 assert_eq!(spec.file_name().unwrap(), "foo.component.spec.ts");
136 assert_eq!(back_to_ts.file_name().unwrap(), "foo.component.ts");
137 }
138
139 #[test]
140 fn cycle_skips_missing_sibling() {
141 let dir = tempdir().unwrap();
142 let ts = touch(dir.path(), "foo.component.ts");
143 touch(dir.path(), "foo.component.spec.ts");
145
146 let cfg = Config::default();
147 let cur = identify_current(&ts, &cfg).unwrap();
148 let next = select(&cur, Mode::CycleForward, None, &cfg).unwrap();
149 assert_eq!(next.file_name().unwrap(), "foo.component.spec.ts");
150 }
151
152 #[test]
153 fn cycle_reverse_goes_backwards() {
154 let dir = tempdir().unwrap();
155 let ts = touch(dir.path(), "foo.component.ts");
156 touch(dir.path(), "foo.component.html");
157 touch(dir.path(), "foo.component.scss");
158 touch(dir.path(), "foo.component.spec.ts");
159
160 let cfg = Config::default();
161 let cur = identify_current(&ts, &cfg).unwrap();
162 let prev = select(&cur, Mode::CycleBackward, None, &cfg).unwrap();
163 assert_eq!(prev.file_name().unwrap(), "foo.component.spec.ts");
164 }
165
166 #[test]
167 fn direct_to_spec() {
168 let dir = tempdir().unwrap();
169 let ts = touch(dir.path(), "foo.component.ts");
170 touch(dir.path(), "foo.component.spec.ts");
171 let cfg = Config::default();
172 let cur = identify_current(&ts, &cfg).unwrap();
173 let spec = select(&cur, Mode::Direct, Some("spec"), &cfg).unwrap();
174 assert_eq!(spec.file_name().unwrap(), "foo.component.spec.ts");
175 }
176
177 #[test]
178 fn direct_unknown_target_errors() {
179 let dir = tempdir().unwrap();
180 let ts = touch(dir.path(), "foo.component.ts");
181 let cfg = Config::default();
182 let cur = identify_current(&ts, &cfg).unwrap();
183 let err = select(&cur, Mode::Direct, Some("nope"), &cfg).unwrap_err();
184 assert!(matches!(err, SwitcherError::Input(_)));
185 }
186
187 #[test]
188 fn direct_missing_sibling_errors() {
189 let dir = tempdir().unwrap();
190 let ts = touch(dir.path(), "foo.component.ts");
191 let cfg = Config::default();
192 let cur = identify_current(&ts, &cfg).unwrap();
193 let err = select(&cur, Mode::Direct, Some("spec"), &cfg).unwrap_err();
194 match err {
195 SwitcherError::NoSibling { target } => assert_eq!(target.as_deref(), Some("spec")),
196 other => panic!("unexpected: {other:?}"),
197 }
198 }
199
200 #[test]
201 fn style_preference_prefers_scss() {
202 let dir = tempdir().unwrap();
203 let ts = touch(dir.path(), "foo.component.ts");
204 touch(dir.path(), "foo.component.css");
205 touch(dir.path(), "foo.component.scss");
206 let cfg = Config::default();
207 let cur = identify_current(&ts, &cfg).unwrap();
208 let style = select(&cur, Mode::Direct, Some("style"), &cfg).unwrap();
209 assert_eq!(style.file_name().unwrap(), "foo.component.scss");
210 }
211
212 #[test]
213 fn no_siblings_returns_no_sibling_err() {
214 let dir = tempdir().unwrap();
215 let ts = touch(dir.path(), "lonely.component.ts");
216 let cfg = Config::default();
217 let cur = identify_current(&ts, &cfg).unwrap();
218 let err = select(&cur, Mode::CycleForward, None, &cfg).unwrap_err();
219 assert!(matches!(err, SwitcherError::NoSibling { .. }));
220 }
221}