unity_solution_generator/
lockfile.rs1use 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 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 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 pub const COUNT: usize = {
75 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 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 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 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 let _ = lock_cache::write(&fp_path, &scanned.lockfile.unity_version, &entries);
198 Ok(scanned.lockfile)
199 }
200
201 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 ¤t {
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 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}