Skip to main content

tsz_cli/
build.rs

1// Copyright 2025 tsz authors. All rights reserved.
2// MIT License.
3
4use anyhow::{Context, Result};
5use std::path::{Path, PathBuf};
6use tracing::{info, warn};
7
8use crate::args::CliArgs;
9use crate::incremental::BuildInfo;
10use crate::project_refs::{ProjectReferenceGraph, ResolvedProject};
11use tsz::checker::diagnostics::DiagnosticCategory;
12
13/// Build mode orchestrator for TypeScript project references.
14///
15/// This is the entry point for `--build` mode, which:
16/// 1. Loads the project reference graph
17/// 2. Determines build order via topological sort
18/// 3. Checks up-to-date status for each project
19/// 4. Compiles dirty projects in dependency order
20pub fn build_solution(args: &CliArgs, cwd: &Path, _root_names: &[String]) -> Result<bool> {
21    // Determine root tsconfig path
22    let root_config = if let Some(project) = args.project.as_deref() {
23        cwd.join(project)
24    } else {
25        // Find tsconfig.json in current directory
26        find_tsconfig(cwd)
27            .ok_or_else(|| anyhow::anyhow!("No tsconfig.json found in {}", cwd.display()))?
28    };
29
30    info!(
31        "Loading project reference graph from: {}",
32        root_config.display()
33    );
34
35    // Load project reference graph
36    let graph = ProjectReferenceGraph::load(&root_config)
37        .context("Failed to load project reference graph")?;
38
39    // Get build order (topological sort)
40    let build_order = graph
41        .build_order()
42        .context("Failed to determine build order (circular dependencies?)")?;
43
44    info!("Build order: {} projects", build_order.len());
45
46    // Track overall success
47    let mut all_success = true;
48    let mut all_diagnostics = Vec::new();
49
50    // Build projects in dependency order
51    for project_id in build_order {
52        let project = graph
53            .get_project(project_id)
54            .ok_or_else(|| anyhow::anyhow!("Project not found: {project_id:?}"))?;
55
56        // Check if project is up-to-date
57        if !args.force && is_project_up_to_date(project, args) {
58            info!("✓ Project is up to date: {}", project.config_path.display());
59            continue;
60        }
61
62        info!("Building project: {}", project.config_path.display());
63
64        // Compile this project
65        let result = crate::driver::compile_project(args, &project.root_dir, &project.config_path)
66            .with_context(|| {
67                format!("Failed to build project: {}", project.config_path.display())
68            })?;
69
70        // Collect diagnostics
71        if !result.diagnostics.is_empty() {
72            all_diagnostics.extend(result.diagnostics.clone());
73
74            // Check for errors
75            let has_errors = result
76                .diagnostics
77                .iter()
78                .any(|d| d.category == DiagnosticCategory::Error);
79
80            if has_errors {
81                all_success = false;
82                warn!("✗ Project has errors: {}", project.config_path.display());
83
84                // Stop on first error unless --force
85                if !args.force {
86                    // Print diagnostics
87                    for diag in &result.diagnostics {
88                        warn!("  {:?}", diag);
89                    }
90                    return Ok(false);
91                }
92            } else {
93                info!(
94                    "✓ Project built with warnings: {}",
95                    project.config_path.display()
96                );
97            }
98        } else {
99            info!(
100                "✓ Project built successfully: {}",
101                project.config_path.display()
102            );
103        }
104    }
105
106    // Print all diagnostics at the end
107    if !all_diagnostics.is_empty() {
108        warn!("\n=== Diagnostics ===");
109        for diag in &all_diagnostics {
110            warn!("{:?}", diag);
111        }
112    }
113
114    Ok(all_success)
115}
116
117/// Check if a project is up-to-date by examining its .tsbuildinfo file
118/// and the outputs of its referenced projects.
119pub fn is_project_up_to_date(project: &ResolvedProject, args: &CliArgs) -> bool {
120    use crate::fs::{FileDiscoveryOptions, discover_ts_files};
121    use crate::incremental::ChangeTracker;
122
123    // Load BuildInfo for this project
124    let build_info_path = match get_build_info_path(project) {
125        Some(path) => path,
126        None => return false,
127    };
128
129    if !build_info_path.exists() {
130        if args.build_verbose {
131            info!("No .tsbuildinfo found at {}", build_info_path.display());
132        }
133        return false;
134    }
135
136    // Try to load BuildInfo
137    let build_info = match BuildInfo::load(&build_info_path) {
138        Ok(Some(info)) => info,
139        Ok(None) => {
140            if args.build_verbose {
141                info!("BuildInfo version mismatch, needs rebuild");
142            }
143            return false;
144        }
145        Err(e) => {
146            if args.build_verbose {
147                warn!(
148                    "Failed to load BuildInfo from {}: {}",
149                    build_info_path.display(),
150                    e
151                );
152            }
153            return false;
154        }
155    };
156
157    // Check if source files have changed using ChangeTracker
158    let root_dir = &project.root_dir;
159
160    // Discover all TypeScript source files in the project
161    // Note: out_dir is passed so output files are excluded from discovery
162    let discovery_options = FileDiscoveryOptions {
163        base_dir: root_dir.clone(),
164        files: Vec::new(),
165        include: None,
166        exclude: None,
167        out_dir: project.out_dir.clone(),
168        follow_links: false,
169        allow_js: false,
170    };
171
172    let current_files = match discover_ts_files(&discovery_options) {
173        Ok(files) => files,
174        Err(e) => {
175            if args.build_verbose {
176                warn!(
177                    "Failed to discover source files in {}: {}",
178                    root_dir.display(),
179                    e
180                );
181            }
182            // If we can't scan files, assume we need to rebuild
183            return false;
184        }
185    };
186
187    // Use ChangeTracker to detect modifications
188    // Note: We pass absolute paths for file reading, but ChangeTracker compares using relative paths
189    let mut tracker = ChangeTracker::new();
190    if let Err(e) = tracker.compute_changes_with_base(&build_info, &current_files, root_dir) {
191        if args.build_verbose {
192            warn!("Failed to compute changes: {}", e);
193        }
194        return false;
195    }
196
197    if tracker.has_changes() {
198        if args.build_verbose {
199            info!(
200                "Project has changes: {} changed, {} new, {} deleted",
201                tracker.changed_files().len(),
202                tracker.new_files().len(),
203                tracker.deleted_files().len()
204            );
205        }
206        return false;
207    }
208
209    // Check if referenced projects' outputs are still valid
210    if !are_referenced_projects_uptodate(project, &build_info, args) {
211        return false;
212    }
213
214    true
215}
216
217/// Check if all referenced projects are up-to-date
218/// by examining their .tsbuildinfo files and output timestamps.
219fn are_referenced_projects_uptodate(
220    project: &ResolvedProject,
221    build_info: &BuildInfo,
222    args: &CliArgs,
223) -> bool {
224    // For each referenced project
225    for reference in &project.resolved_references {
226        let project_dir = reference
227            .config_path
228            .parent()
229            .unwrap_or(reference.config_path.as_path());
230
231        let ref_build_info_path = project_dir.join("tsconfig.tsbuildinfo");
232
233        if !ref_build_info_path.exists() {
234            if args.build_verbose {
235                let project_name = reference
236                    .config_path
237                    .file_stem()
238                    .and_then(|s| s.to_str())
239                    .unwrap_or("unknown");
240                info!("Referenced project not built: {}", project_name);
241            }
242            return false;
243        }
244
245        match BuildInfo::load(&ref_build_info_path) {
246            Ok(Some(ref_build_info)) => {
247                // Check if the referenced project's latest .d.ts file is newer
248                // than our build time, which would mean we need to rebuild
249                if let Some(ref latest_dts) = ref_build_info.latest_changed_dts_file {
250                    // Convert relative path to absolute path
251                    let dts_absolute_path = project_dir.join(latest_dts);
252
253                    // Get the modification time of the .d.ts file
254                    if let Ok(metadata) = std::fs::metadata(&dts_absolute_path)
255                        && let Ok(dts_modified) = metadata.modified()
256                    {
257                        // Convert the .d.ts modification time to seconds since epoch
258                        if let Ok(dts_secs) = dts_modified.duration_since(std::time::UNIX_EPOCH) {
259                            let dts_timestamp = dts_secs.as_secs();
260
261                            // Compare with our build time
262                            if dts_timestamp > build_info.build_time {
263                                if args.build_verbose {
264                                    let project_name = reference
265                                        .config_path
266                                        .file_stem()
267                                        .and_then(|s| s.to_str())
268                                        .unwrap_or("unknown");
269                                    info!(
270                                        "Referenced project's .d.ts is newer: {} ({} > {})",
271                                        project_name, dts_timestamp, build_info.build_time
272                                    );
273                                }
274                                return false;
275                            }
276                        }
277                    }
278                }
279            }
280            Ok(None) => {
281                if args.build_verbose {
282                    let project_name = reference
283                        .config_path
284                        .file_stem()
285                        .and_then(|s| s.to_str())
286                        .unwrap_or("unknown");
287                    info!("Referenced project has version mismatch: {}", project_name);
288                }
289                return false;
290            }
291            Err(e) => {
292                if args.build_verbose {
293                    let project_name = reference
294                        .config_path
295                        .file_stem()
296                        .and_then(|s| s.to_str())
297                        .unwrap_or("unknown");
298                    warn!("Failed to load BuildInfo for {}: {}", project_name, e);
299                }
300                return false;
301            }
302        }
303    }
304
305    true
306}
307
308/// Get the path to the .tsbuildinfo file for a project
309fn get_build_info_path(project: &ResolvedProject) -> Option<PathBuf> {
310    use crate::incremental::default_build_info_path;
311
312    // Use the same logic as incremental.rs
313    let out_dir = project.out_dir.as_deref();
314    Some(default_build_info_path(&project.config_path, out_dir))
315}
316
317/// Find a tsconfig.json file in the given directory
318fn find_tsconfig(dir: &Path) -> Option<PathBuf> {
319    let config = dir.join("tsconfig.json");
320    config.exists().then_some(config)
321}