Skip to main content

unity_solution_generator/
lockfile.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use crate::error::{LockfileError, Result};
5use crate::io::{create_dir_all, read_file, write_file_if_changed};
6use crate::lock_cache;
7use crate::lockfile_scanner::LockfileScanner;
8use crate::paths::{join_path, lockfile_path, parent_directory};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct DllRef {
12    pub name: String,
13    pub path: String,
14}
15
16impl DllRef {
17    pub fn new(name: impl Into<String>, path: impl Into<String>) -> Self {
18        Self {
19            name: name.into(),
20            path: path.into(),
21        }
22    }
23
24    /// Parse a comma-separated list of absolute DLL paths, inferring each
25    /// `name` from the filename (with the `.dll` suffix stripped).
26    pub fn parse_list(comma_separated: &str) -> Vec<DllRef> {
27        comma_separated
28            .split(',')
29            .filter(|s| !s.is_empty())
30            .map(|part| {
31                let path = part.to_string();
32                let filename = path.rsplit('/').next().unwrap_or(&path);
33                let name = filename
34                    .strip_suffix(".dll")
35                    .map(str::to_string)
36                    .unwrap_or_else(|| filename.to_string());
37                DllRef { name, path }
38            })
39            .collect()
40    }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub enum RefCategory {
45    Engine,
46    Editor,
47    Netstandard,
48    PlaybackIos,
49    PlaybackAndroid,
50    PlaybackStandalone,
51    Project,
52}
53
54impl RefCategory {
55    /// All variants in the canonical iteration order used by `LockfileIO::write`.
56    ///
57    /// Adding a new variant has to update **both** the array literal AND the
58    /// length below; the const assertion ties them together so a forgotten array
59    /// entry fails compilation. The exhaustive match in `as_section`/`from_section`
60    /// catches the variant on the first build attempt; this assertion catches it
61    /// on the second (when the developer updates the match but forgets the array).
62    pub const ALL: [RefCategory; Self::COUNT] = [
63        RefCategory::Engine,
64        RefCategory::Editor,
65        RefCategory::Netstandard,
66        RefCategory::PlaybackIos,
67        RefCategory::PlaybackAndroid,
68        RefCategory::PlaybackStandalone,
69        RefCategory::Project,
70    ];
71
72    /// Number of variants. Bumping this is enforced by the array literal above —
73    /// the array length must match this constant or compilation fails.
74    pub const COUNT: usize = {
75        // One arm per variant. Adding a new enum variant fails the exhaustive
76        // match here, forcing the developer to also bump COUNT and add to ALL.
77        let count_per_variant = |v: RefCategory| -> usize {
78            match v {
79                RefCategory::Engine => 1,
80                RefCategory::Editor => 1,
81                RefCategory::Netstandard => 1,
82                RefCategory::PlaybackIos => 1,
83                RefCategory::PlaybackAndroid => 1,
84                RefCategory::PlaybackStandalone => 1,
85                RefCategory::Project => 1,
86            }
87        };
88        let _ = count_per_variant;
89        7
90    };
91
92    pub fn as_section(self) -> &'static str {
93        match self {
94            RefCategory::Engine => "refs.engine",
95            RefCategory::Editor => "refs.editor",
96            RefCategory::Netstandard => "refs.netstandard",
97            RefCategory::PlaybackIos => "refs.playback.ios",
98            RefCategory::PlaybackAndroid => "refs.playback.android",
99            RefCategory::PlaybackStandalone => "refs.playback.standalone",
100            RefCategory::Project => "refs.project",
101        }
102    }
103
104    pub fn from_section(name: &str) -> Option<Self> {
105        Some(match name {
106            "refs.engine" => RefCategory::Engine,
107            "refs.editor" => RefCategory::Editor,
108            "refs.netstandard" => RefCategory::Netstandard,
109            "refs.playback.ios" => RefCategory::PlaybackIos,
110            "refs.playback.android" => RefCategory::PlaybackAndroid,
111            "refs.playback.standalone" => RefCategory::PlaybackStandalone,
112            "refs.project" => RefCategory::Project,
113            _ => return None,
114        })
115    }
116}
117
118#[derive(Debug, Clone)]
119pub struct Lockfile {
120    pub unity_version: String,
121    pub unity_path: String,
122    pub lang_version: String,
123    pub analyzers: Vec<String>,
124    pub refs: BTreeMap<RefCategory, Vec<DllRef>>,
125    pub defines: Vec<String>,
126    pub defines_scripting: Vec<String>,
127}
128
129impl Lockfile {
130    /// Build an empty lockfile shell with all `RefCategory` keys populated to
131    /// empty Vecs. Useful for tests and for downstream crates that want to
132    /// programmatically construct a Lockfile without spelling out every category.
133    pub fn empty(unity_version: impl Into<String>, unity_path: impl Into<String>) -> Self {
134        let mut refs: BTreeMap<RefCategory, Vec<DllRef>> = BTreeMap::new();
135        for cat in RefCategory::ALL {
136            refs.insert(cat, Vec::new());
137        }
138        Lockfile {
139            unity_version: unity_version.into(),
140            unity_path: unity_path.into(),
141            lang_version: "9.0".to_string(),
142            analyzers: Vec::new(),
143            refs,
144            defines: Vec::new(),
145            defines_scripting: Vec::new(),
146        }
147    }
148
149    pub fn total_ref_count(&self) -> usize {
150        self.refs.values().map(|v| v.len()).sum()
151    }
152
153    pub fn refs_for(&self, cat: RefCategory) -> &[DllRef] {
154        self.refs.get(&cat).map(Vec::as_slice).unwrap_or(&[])
155    }
156}
157
158pub struct LockfileIO;
159
160impl LockfileIO {
161    /// Scan + write the lockfile (creating the generator dir if needed).
162    /// `generator_root` controls where `csproj.lock` and `lock-fingerprint` live;
163    /// pass [`DEFAULT_GENERATOR_ROOT`] for the standard layout.
164    ///
165    /// Short-circuits when the recorded fingerprint (see [`lock_cache`]) shows nothing
166    /// has changed since the last `lock`. In the cache-hit path no Unity-install scan
167    /// or project-side walk runs; we just refresh the fingerprint timestamps and return
168    /// the existing lockfile.
169    pub fn scan_and_write(project_root: &str, generator_root: &str) -> Result<Lockfile> {
170        let path = lockfile_path(project_root, generator_root);
171        let generator_dir = join_path(project_root, generator_root);
172        let fp_path = lock_cache::fingerprint_path(&generator_dir);
173        create_dir_all(parent_directory(&path));
174
175        // Fast path: fingerprint matches and lockfile is still on disk.
176        if std::path::Path::new(&path).exists() {
177            if let Some(entries) = lock_cache::load(&fp_path) {
178                if lock_cache::is_valid(&entries) {
179                    if let Ok(existing) = Self::read(&path) {
180                        return Ok(existing);
181                    }
182                }
183            }
184        }
185
186        let scanned = LockfileScanner::scan_with_artifacts(project_root)?;
187        Self::write(&scanned.lockfile, &path)?;
188
189        let entries = lock_cache::build_entries(
190            project_root,
191            &scanned.lockfile.unity_path,
192            &scanned.contributing_paths_relative,
193            &scanned.contributing_external_absolute,
194        );
195        // Best-effort: a fingerprint write failure must not fail the user-facing
196        // operation; we'd just rescan next time.
197        let _ = lock_cache::write(&fp_path, &scanned.lockfile.unity_version, &entries);
198        Ok(scanned.lockfile)
199    }
200
201    /// Read the lockfile if present, else scan + write a fresh one.
202    /// See [`scan_and_write`](Self::scan_and_write) for the `generator_root` argument.
203    pub fn load_or_scan(project_root: &str, generator_root: &str) -> Result<Lockfile> {
204        let path = lockfile_path(project_root, generator_root);
205        if Path::new(&path).exists() {
206            Self::read(&path)
207        } else {
208            Self::scan_and_write(project_root, generator_root)
209        }
210    }
211
212    pub fn write(lockfile: &Lockfile, path: &str) -> Result<()> {
213        let mut s = String::new();
214        s.push_str("# csproj.lock — auto-generated by unity-solution-generator lock\n");
215        s.push_str("# Re-run when: Unity version changes, packages added/removed\n\n");
216        s.push_str(&format!("unity-version: {}\n", lockfile.unity_version));
217        s.push_str(&format!("unity-path: {}\n", lockfile.unity_path));
218        s.push_str(&format!("lang-version: {}\n", lockfile.lang_version));
219
220        write_section(&mut s, "analyzers", &lockfile.analyzers);
221        for cat in RefCategory::ALL {
222            write_ref_section(&mut s, cat.as_section(), lockfile.refs_for(cat));
223        }
224
225        s.push_str("\n[defines]\n");
226        s.push_str(&lockfile.defines.join(";"));
227        s.push('\n');
228
229        s.push_str("\n[defines.scripting]\n");
230        s.push_str(&lockfile.defines_scripting.join(";"));
231        s.push('\n');
232
233        write_file_if_changed(path, &s)?;
234        Ok(())
235    }
236
237    pub fn read(path: &str) -> Result<Lockfile> {
238        let content = read_file(path)?;
239
240        let mut unity_version = String::new();
241        let mut unity_path = String::new();
242        let mut lang_version = String::from("9.0");
243        let mut analyzers: Vec<String> = Vec::new();
244        let mut refs: BTreeMap<RefCategory, Vec<DllRef>> = BTreeMap::new();
245        let mut defines: Vec<String> = Vec::new();
246        let mut defines_scripting: Vec<String> = Vec::new();
247
248        enum Section {
249            Analyzers,
250            Ref(RefCategory),
251            Defines,
252            DefinesScripting,
253        }
254        let mut current: Option<Section> = None;
255
256        for line in content.split('\n') {
257            if line.is_empty() || line.starts_with('#') {
258                continue;
259            }
260
261            if line.starts_with('[') && line.ends_with(']') {
262                let name = &line[1..line.len() - 1];
263                current = if let Some(cat) = RefCategory::from_section(name) {
264                    Some(Section::Ref(cat))
265                } else {
266                    match name {
267                        "analyzers" => Some(Section::Analyzers),
268                        "defines" => Some(Section::Defines),
269                        "defines.scripting" => Some(Section::DefinesScripting),
270                        _ => None,
271                    }
272                };
273                continue;
274            }
275
276            match &current {
277                None => {
278                    if let Some((k, v)) = parse_header_line(line) {
279                        match k {
280                            "unity-version" => unity_version = v.to_string(),
281                            "unity-path" => unity_path = v.to_string(),
282                            "lang-version" => lang_version = v.to_string(),
283                            _ => {}
284                        }
285                    }
286                }
287                Some(Section::Analyzers) => analyzers.push(line.to_string()),
288                Some(Section::Ref(cat)) => {
289                    if let Some(r) = parse_dll_ref(line) {
290                        refs.entry(*cat).or_default().push(r);
291                    }
292                }
293                // The writer always emits a single semicolon-delimited line per
294                // section, but a hand-edited or future writer could spill across
295                // multiple lines. Use `extend` so we don't silently drop everything
296                // but the last line.
297                Some(Section::Defines) => {
298                    if !line.is_empty() {
299                        defines.extend(line.split(';').map(str::to_string));
300                    }
301                }
302                Some(Section::DefinesScripting) => {
303                    if !line.is_empty() {
304                        defines_scripting.extend(line.split(';').map(str::to_string));
305                    }
306                }
307            }
308        }
309
310        if unity_version.is_empty() {
311            return Err(LockfileError::InvalidLockfile("missing unity-version".into()).into());
312        }
313        if unity_path.is_empty() {
314            return Err(LockfileError::InvalidLockfile("missing unity-path".into()).into());
315        }
316
317        Ok(Lockfile {
318            unity_version,
319            unity_path,
320            lang_version,
321            analyzers,
322            refs,
323            defines,
324            defines_scripting,
325        })
326    }
327}
328
329fn write_section(s: &mut String, name: &str, lines: &[String]) {
330    s.push_str(&format!("\n[{}]\n", name));
331    for line in lines {
332        s.push_str(line);
333        s.push('\n');
334    }
335}
336
337fn write_ref_section(s: &mut String, name: &str, refs: &[DllRef]) {
338    s.push_str(&format!("\n[{}]\n", name));
339    for r in refs {
340        s.push_str(&r.name);
341        s.push('|');
342        s.push_str(&r.path);
343        s.push('\n');
344    }
345}
346
347fn parse_header_line(line: &str) -> Option<(&str, &str)> {
348    let colon = line.find(':')?;
349    let key = &line[..colon];
350    let value = line[colon + 1..].trim();
351    Some((key, value))
352}
353
354fn parse_dll_ref(line: &str) -> Option<DllRef> {
355    let pipe = line.find('|')?;
356    Some(DllRef {
357        name: line[..pipe].to_string(),
358        path: line[pipe + 1..].to_string(),
359    })
360}