1use std::path::Path;
8
9use serde::Serialize;
10
11#[derive(Debug, Clone, Default)]
13pub struct ProjectSettings {
14 pub sdk_path: String,
16 pub python_path: String,
18 pub board_yaml_path: String,
20 pub west_cwd: String,
22}
23
24#[derive(Debug, Clone)]
26pub struct ProjectResolutionInput {
27 pub workspace_folders: Vec<String>,
29 pub settings: ProjectSettings,
31 pub is_windows: bool,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct ProjectContext {
39 pub workspace_root: Option<String>,
41 pub sdk_root: Option<String>,
43 pub board_yaml_path: Option<String>,
45 pub west_cwd: Option<String>,
47 pub python_binary: String,
49}
50
51fn contains_loader_script(root: &str, path_exists: &impl Fn(&str) -> bool) -> bool {
53 let marker = Path::new(root).join("scripts").join("alp_project.py");
54 path_exists(&marker.to_string_lossy())
55}
56
57fn resolve_workspace_root(workspace_folders: &[String]) -> Option<String> {
58 workspace_folders.first().cloned()
59}
60
61fn collect_sdk_candidates(
62 workspace_folders: &[String],
63 path_exists: &impl Fn(&str) -> bool,
64) -> Vec<String> {
65 let mut candidates: Vec<String> = Vec::new();
66 let push_unique = |value: String, out: &mut Vec<String>| {
67 if !out.contains(&value) {
68 out.push(value);
69 }
70 };
71
72 for folder in workspace_folders {
73 if contains_loader_script(folder, path_exists) {
75 push_unique(folder.clone(), &mut candidates);
76 }
77
78 if let Some(parent) = Path::new(folder).parent() {
79 let sibling = parent.join("alp-sdk");
80 let sibling = sibling.to_string_lossy().to_string();
81 if contains_loader_script(&sibling, path_exists) {
82 push_unique(sibling, &mut candidates);
83 }
84 }
85 }
86
87 candidates
88}
89
90fn resolve_sdk_root(
91 workspace_folders: &[String],
92 configured_sdk_path: &str,
93 path_exists: &impl Fn(&str) -> bool,
94) -> Option<String> {
95 let trimmed = configured_sdk_path.trim();
97 if !trimmed.is_empty() {
98 return if contains_loader_script(trimmed, path_exists) {
99 Some(trimmed.to_string())
100 } else {
101 None
102 };
103 }
104
105 let candidates = collect_sdk_candidates(workspace_folders, path_exists);
107 if candidates.len() == 1 {
108 return candidates.into_iter().next();
109 }
110
111 None
112}
113
114fn resolve_board_yaml_path(
115 workspace_root: Option<&str>,
116 configured_board_yaml_path: &str,
117) -> Option<String> {
118 let root = workspace_root?;
119 let configured = Path::new(configured_board_yaml_path);
120 if configured.is_absolute() {
121 return Some(configured_board_yaml_path.to_string());
122 }
123 Some(
124 Path::new(root)
125 .join(configured)
126 .to_string_lossy()
127 .to_string(),
128 )
129}
130
131fn resolve_west_cwd(workspace_root: Option<&str>, configured_west_cwd: &str) -> Option<String> {
132 let trimmed = configured_west_cwd.trim();
133 if !trimmed.is_empty() {
134 return Some(trimmed.to_string());
135 }
136 workspace_root.map(str::to_string)
137}
138
139fn resolve_python_binary(configured_python_path: &str, is_windows: bool) -> String {
140 let trimmed = configured_python_path.trim();
141 if !trimmed.is_empty() {
142 return trimmed.to_string();
143 }
144 if is_windows { "python" } else { "python3" }.to_string()
145}
146
147pub fn resolve_project_context(
149 input: &ProjectResolutionInput,
150 path_exists: impl Fn(&str) -> bool,
151) -> ProjectContext {
152 let workspace_root = resolve_workspace_root(&input.workspace_folders);
153
154 ProjectContext {
155 sdk_root: resolve_sdk_root(
156 &input.workspace_folders,
157 &input.settings.sdk_path,
158 &path_exists,
159 ),
160 board_yaml_path: resolve_board_yaml_path(
161 workspace_root.as_deref(),
162 &input.settings.board_yaml_path,
163 ),
164 west_cwd: resolve_west_cwd(workspace_root.as_deref(), &input.settings.west_cwd),
165 python_binary: resolve_python_binary(&input.settings.python_path, input.is_windows),
166 workspace_root,
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn input(folders: &[&str], settings: ProjectSettings) -> ProjectResolutionInput {
175 ProjectResolutionInput {
176 workspace_folders: folders.iter().map(|s| s.to_string()).collect(),
177 settings,
178 is_windows: false,
179 }
180 }
181
182 #[test]
183 fn python_binary_defaults_per_platform() {
184 assert_eq!(resolve_python_binary("", false), "python3");
185 assert_eq!(resolve_python_binary("", true), "python");
186 assert_eq!(
187 resolve_python_binary(" /usr/bin/py ", false),
188 "/usr/bin/py"
189 );
190 }
191
192 #[test]
193 fn board_yaml_path_joins_relative_under_workspace() {
194 let ctx = resolve_project_context(
195 &input(
196 &["/work/proj"],
197 ProjectSettings {
198 board_yaml_path: "board.yaml".to_string(),
199 ..Default::default()
200 },
201 ),
202 |_| false,
203 );
204 assert_eq!(
205 ctx.board_yaml_path.as_deref(),
206 Some("/work/proj/board.yaml")
207 );
208 assert_eq!(ctx.west_cwd.as_deref(), Some("/work/proj"));
209 }
210
211 #[test]
212 fn board_yaml_absolute_is_preserved() {
213 let ctx = resolve_project_context(
214 &input(
215 &["/work/proj"],
216 ProjectSettings {
217 board_yaml_path: "/etc/board.yaml".to_string(),
218 ..Default::default()
219 },
220 ),
221 |_| false,
222 );
223 assert_eq!(ctx.board_yaml_path.as_deref(), Some("/etc/board.yaml"));
224 }
225
226 #[test]
227 fn explicit_sdk_path_requires_loader_script() {
228 let with_loader = resolve_sdk_root(&[], "/sdk", &|p| p == "/sdk/scripts/alp_project.py");
229 assert_eq!(with_loader.as_deref(), Some("/sdk"));
230
231 let without_loader = resolve_sdk_root(&[], "/sdk", &|_| false);
232 assert_eq!(without_loader, None);
233 }
234
235 #[test]
236 fn auto_discovers_workspace_root_as_sdk() {
237 let ctx = resolve_project_context(
238 &input(&["/work/sdkroot"], ProjectSettings::default()),
239 |p| p == "/work/sdkroot/scripts/alp_project.py",
240 );
241 assert_eq!(ctx.sdk_root.as_deref(), Some("/work/sdkroot"));
242 }
243
244 #[test]
245 fn auto_discovers_sibling_alp_sdk() {
246 let ctx =
247 resolve_project_context(&input(&["/work/proj"], ProjectSettings::default()), |p| {
248 p == "/work/alp-sdk/scripts/alp_project.py"
249 });
250 assert_eq!(ctx.sdk_root.as_deref(), Some("/work/alp-sdk"));
251 }
252
253 #[test]
254 fn ambiguous_sdk_candidates_resolve_to_none() {
255 let ctx =
257 resolve_project_context(&input(&["/work/proj"], ProjectSettings::default()), |p| {
258 p == "/work/proj/scripts/alp_project.py"
259 || p == "/work/alp-sdk/scripts/alp_project.py"
260 });
261 assert_eq!(ctx.sdk_root, None);
262 }
263
264 #[test]
265 fn no_workspace_folder_yields_null_root_and_paths() {
266 let ctx = resolve_project_context(&input(&[], ProjectSettings::default()), |_| false);
267 assert_eq!(ctx.workspace_root, None);
268 assert_eq!(ctx.board_yaml_path, None);
269 assert_eq!(ctx.west_cwd, None);
270 }
271}