Skip to main content

pcdl_build/
lib.rs

1use std::env;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5const PCDL_APP_VERSION: &str = "0.1.0";
6const RUST_MIR2_WRAPPER_VERSION: &str = "0.1.2";
7const RUST_MIR2_VERSION: &str = "0.1.2";
8const MIR2PCDL_VERSION: &str = "0.1.2";
9
10pub fn maybe_open_editor() {
11    println!("cargo:rerun-if-env-changed=PCDL_OPEN");
12    println!("cargo:rerun-if-env-changed=PCDL_APP_BIN");
13    println!("cargo:rerun-if-env-changed=PCDL_RUST_MIR2_BIN");
14    println!("cargo:rerun-if-env-changed=RUST_MIR2_WRAPPER_BIN");
15    println!("cargo:rerun-if-env-changed=PCDL_PACKAGE_NAME");
16    println!("cargo:rerun-if-env-changed=PCDL_2R_DIR");
17    println!("cargo:rerun-if-env-changed=PCDL_APP_PORT");
18    println!("cargo:rerun-if-env-changed=PCDL_FRONTEND_PORT");
19
20    if env::var("PCDL_OPEN").ok().as_deref() != Some("1") {
21        return;
22    }
23
24    if let Err(error) = install_published_tools() {
25        warn(&format!("failed to install published PCDL tools: {error}"));
26        return;
27    }
28
29    let cargo_bin_dir = cargo_bin_dir();
30    let rust_mir2_bin = env::var_os("PCDL_RUST_MIR2_BIN")
31        .map(PathBuf::from)
32        .unwrap_or_else(|| binary_path(&cargo_bin_dir, "rust_mir2"));
33    let wrapper_bin = env::var_os("RUST_MIR2_WRAPPER_BIN")
34        .map(PathBuf::from)
35        .unwrap_or_else(|| binary_path(&cargo_bin_dir, "rust_mir2-wrapper"));
36    let app_bin = env::var_os("PCDL_APP_BIN")
37        .map(PathBuf::from)
38        .unwrap_or_else(|| binary_path(&cargo_bin_dir, "pcdl-app"));
39
40    let Some(manifest_dir) = env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from) else {
41        warn("missing CARGO_MANIFEST_DIR");
42        return;
43    };
44    let project_root = workspace_or_manifest_root(&manifest_dir);
45    let file_config = PcdlBuildConfig::load(&project_root.join("pcdl.toml")).unwrap_or_default();
46    let source_project = env::var("PCDL_SOURCE_PROJECT")
47        .map(PathBuf::from)
48        .ok()
49        .or(file_config.source_project.map(PathBuf::from))
50        .map(|path| resolve_config_path(&project_root, path))
51        .unwrap_or_else(|| project_root.clone());
52    let package_name = env::var("PCDL_PACKAGE_NAME").unwrap_or_else(|_| {
53        file_config
54            .package_name
55            .unwrap_or_else(|| env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "unknown".to_string()))
56    });
57    let two_r_dir = env::var("PCDL_2R_DIR")
58        .map(PathBuf::from)
59        .ok()
60        .or(file_config.two_r_dir.map(PathBuf::from))
61        .map(|path| resolve_config_path(&project_root, path))
62        .map(|path| path.display().to_string())
63        .unwrap_or_else(|| project_root.join(".pcdl/2r").display().to_string());
64    let backend_port = env::var("PCDL_APP_PORT")
65        .ok()
66        .or(file_config.backend_port)
67        .unwrap_or_else(|| "8787".to_string());
68    let frontend_port = env::var("PCDL_FRONTEND_PORT")
69        .ok()
70        .or(file_config.frontend_port)
71        .unwrap_or_else(|| "3000".to_string());
72    let log_dir = project_root.join(".pcdl");
73    let log_path = log_dir.join("pcdl_app.log");
74
75    if let Err(error) = std::fs::create_dir_all(&log_dir) {
76        warn(&format!("failed to create PCDL log dir: {error}"));
77        return;
78    }
79
80    if is_port_open(&backend_port) {
81        println!("cargo:warning=PCDL editor already appears to be running on port {backend_port}");
82        return;
83    }
84
85    let mut command = Command::new(app_bin);
86    command
87        .arg("open")
88        .arg("--project")
89        .arg(source_project)
90        .arg("--package")
91        .arg(package_name)
92        .arg("--2r-dir")
93        .arg(two_r_dir)
94        .arg("--backend-port")
95        .arg(&backend_port)
96        .arg("--frontend-port")
97        .arg(frontend_port)
98        .arg("--open-browser")
99        .env("PCDL_RUST_MIR2_BIN", &rust_mir2_bin)
100        .env("RUST_MIR2_WRAPPER_BIN", &wrapper_bin)
101        .current_dir(&project_root)
102        .stdin(Stdio::null())
103        .stdout(log_file(&log_path))
104        .stderr(log_file(&log_path));
105
106    match command.spawn() {
107        Ok(_) => println!(
108            "cargo:warning=PCDL editor launch requested; log: {}",
109            log_path.display()
110        ),
111        Err(error) => warn(&format!(
112            "failed to launch pcdl-app; install it or set PCDL_APP_BIN: {error}"
113        )),
114    }
115}
116
117fn workspace_or_manifest_root(manifest_dir: &Path) -> PathBuf {
118    let mut current = Some(manifest_dir);
119
120    while let Some(dir) = current {
121        if dir.join("Cargo.toml").is_file() && has_workspace_manifest(&dir.join("Cargo.toml")) {
122            return dir.to_path_buf();
123        }
124
125        current = dir.parent();
126    }
127
128    manifest_dir.to_path_buf()
129}
130
131fn install_published_tools() -> Result<(), String> {
132    install_published_tool("pcdl-app", PCDL_APP_VERSION, None)?;
133    install_published_tool(
134        "rust_mir2_wrapper",
135        RUST_MIR2_WRAPPER_VERSION,
136        Some("nightly"),
137    )?;
138    install_published_tool("rust_mir2", RUST_MIR2_VERSION, None)?;
139    install_published_tool("mir2pcdl", MIR2PCDL_VERSION, None)?;
140    Ok(())
141}
142
143fn install_published_tool(
144    crate_name: &str,
145    version: &str,
146    toolchain: Option<&str>,
147) -> Result<(), String> {
148    let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
149    let mut command = Command::new(cargo);
150    if let Some(toolchain) = toolchain {
151        command.arg(format!("+{toolchain}"));
152    }
153    command
154        .arg("install")
155        .arg(crate_name)
156        .arg("--version")
157        .arg(version);
158
159    let status = command
160        .status()
161        .map_err(|error| format!("failed to run cargo install for {crate_name}: {error}"))?;
162
163    if !status.success() {
164        return Err(format!(
165            "cargo install {crate_name} --version {version} failed with status {status}"
166        ));
167    }
168
169    Ok(())
170}
171
172fn cargo_bin_dir() -> PathBuf {
173    env::var_os("CARGO_HOME")
174        .map(PathBuf::from)
175        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))
176        .unwrap_or_else(|| PathBuf::from(".cargo"))
177        .join("bin")
178}
179
180fn binary_path(dir: &Path, name: &str) -> PathBuf {
181    if cfg!(target_os = "windows") {
182        dir.join(format!("{name}.exe"))
183    } else {
184        dir.join(name)
185    }
186}
187
188fn resolve_config_path(base: &Path, path: PathBuf) -> PathBuf {
189    if path.is_absolute() {
190        path
191    } else {
192        base.join(path)
193    }
194}
195
196fn has_workspace_manifest(path: &Path) -> bool {
197    std::fs::read_to_string(path)
198        .map(|text| text.contains("[workspace]"))
199        .unwrap_or(false)
200}
201
202fn is_port_open(port: &str) -> bool {
203    let Ok(port) = port.parse::<u16>() else {
204        return false;
205    };
206
207    std::net::TcpStream::connect_timeout(
208        &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
209        std::time::Duration::from_millis(150),
210    )
211    .is_ok()
212}
213
214#[derive(Default)]
215struct PcdlBuildConfig {
216    source_project: Option<String>,
217    package_name: Option<String>,
218    two_r_dir: Option<String>,
219    backend_port: Option<String>,
220    frontend_port: Option<String>,
221}
222
223impl PcdlBuildConfig {
224    fn load(path: &Path) -> Option<Self> {
225        let text = std::fs::read_to_string(path).ok()?;
226        let mut config = Self::default();
227
228        for line in text.lines() {
229            let line = line.split('#').next().unwrap_or("").trim();
230            if line.is_empty() {
231                continue;
232            }
233
234            let Some((key, value)) = line.split_once('=') else {
235                continue;
236            };
237            let key = key.trim();
238            let value = value
239                .trim()
240                .trim_matches('"')
241                .trim_matches('\'')
242                .to_string();
243
244            match key {
245                "source_project" => config.source_project = Some(value),
246                "package_name" => config.package_name = Some(value),
247                "two_r_dir" => config.two_r_dir = Some(value),
248                "backend_port" => config.backend_port = Some(value),
249                "frontend_port" => config.frontend_port = Some(value),
250                _ => {}
251            }
252        }
253
254        Some(config)
255    }
256}
257
258fn warn(message: &str) {
259    println!("cargo:warning={message}");
260}
261
262fn log_file(path: &Path) -> Stdio {
263    std::fs::OpenOptions::new()
264        .create(true)
265        .append(true)
266        .open(path)
267        .map(Stdio::from)
268        .unwrap_or_else(|_| Stdio::null())
269}