Skip to main content

alp_core/
project.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Workspace/project resolution — a port of the TypeScript
3//! `resolveProjectContext`. Filesystem access is injected as a `path_exists`
4//! predicate so the resolution logic stays pure and unit-testable; the CLI
5//! supplies the real `Path::exists` probe.
6
7use std::path::Path;
8
9use serde::Serialize;
10
11/// User-configured override settings; empty strings mean "fall back to defaults / auto-discovery".
12#[derive(Debug, Clone, Default)]
13pub struct ProjectSettings {
14    /// Explicit SDK root; honored only if it contains the loader marker, else yields no `sdk_root`.
15    pub sdk_path: String,
16    /// Explicit Python interpreter; empty falls back to the per-platform default.
17    pub python_path: String,
18    /// `board.yaml` location; resolved relative to the workspace root when not absolute.
19    pub board_yaml_path: String,
20    /// Working directory for `west` invocations; empty falls back to the workspace root.
21    pub west_cwd: String,
22}
23
24/// All inputs needed to resolve a `ProjectContext` in one pass.
25#[derive(Debug, Clone)]
26pub struct ProjectResolutionInput {
27    /// Open workspace folders; the first is treated as the workspace root.
28    pub workspace_folders: Vec<String>,
29    /// User-configured overrides.
30    pub settings: ProjectSettings,
31    /// Host platform flag, selecting the default Python binary name.
32    pub is_windows: bool,
33}
34
35/// Resolved project context shared by every CLI/IDE surface; serialized as camelCase JSON.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct ProjectContext {
39    /// First workspace folder, or `None` when no folder is open.
40    pub workspace_root: Option<String>,
41    /// Detected/configured SDK root, or `None` when absent or ambiguous.
42    pub sdk_root: Option<String>,
43    /// Absolute path to `board.yaml`, or `None` without a workspace root.
44    pub board_yaml_path: Option<String>,
45    /// Working directory for `west`, or `None` without a workspace root.
46    pub west_cwd: Option<String>,
47    /// Python interpreter to invoke (configured value or platform default).
48    pub python_binary: String,
49}
50
51/// `scripts/alp_project.py` is the canonical marker for an ALP SDK root.
52fn 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        // Check both the workspace root and the conventional sibling alp-sdk folder.
74        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    // Prefer the explicit SDK path, but only if it contains the loader entrypoint.
96    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    // Auto-discovery is valid only when exactly one SDK root is detected.
106    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
147/// Resolve every runtime input once so each surface reads the same context.
148pub 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        // Both the workspace root and its sibling qualify -> ambiguous -> None.
256        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}