Skip to main content

intentional_core/adapters/
ecosystem.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Thin npm and Cargo specializations over JSON and TOML format adapters.
7
8use super::format::{FormatAdapter, JsonFormat, TomlFormat, YamlFormat};
9use crate::error::{Error, Result};
10use semver::Version as SemverVersion;
11use toml_edit::{value, DocumentMut, Item, Value};
12
13/// npm `package.json` adapter.
14#[derive(Debug, Clone, Copy, Default)]
15pub struct NpmAdapter;
16
17impl NpmAdapter {
18    /// Read the npm package name.
19    pub fn name(self, text: &str) -> Result<String> {
20        JsonFormat.read_text(text, "/name")
21    }
22
23    /// Read the manifest version.
24    pub fn version(self, text: &str) -> Result<String> {
25        JsonFormat.read_text(text, "/version")
26    }
27
28    /// Replace only the npm version value.
29    pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
30        JsonFormat.edit_text(text, "/version", version)
31    }
32
33    /// Rewrite an internal dependency range wherever npm permits it.
34    pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
35        let mut edited = text.to_owned();
36        let mut found = false;
37        for group in ["dependencies", "devDependencies", "peerDependencies"] {
38            let pointer = format!("/{group}/{}", escape_json_pointer(name));
39            if let Ok(existing) = JsonFormat.read_text(&edited, &pointer) {
40                let range = npm_range(&existing, version);
41                edited = JsonFormat.edit_text(&edited, &pointer, &range)?;
42                found = true;
43            }
44        }
45        if !found {
46            return Err(Error::Validation(format!(
47                "npm manifest does not declare internal dependency {name}"
48            )));
49        }
50        Ok(edited)
51    }
52}
53
54/// Cargo `Cargo.toml` adapter.
55#[derive(Debug, Clone, Copy, Default)]
56pub struct CargoAdapter;
57
58/// Dart/Flutter `pubspec.yaml` adapter.
59#[derive(Debug, Clone, Copy, Default)]
60pub struct PubAdapter;
61
62/// PEP 621 `pyproject.toml` adapter.
63#[derive(Debug, Clone, Copy, Default)]
64pub struct PythonAdapter;
65
66/// MSBuild project adapter using targeted XML element edits.
67#[derive(Debug, Clone, Copy, Default)]
68pub struct MsbuildAdapter;
69
70/// Go module adapter.
71#[derive(Debug, Clone, Copy, Default)]
72pub struct GoAdapter;
73
74impl CargoAdapter {
75    /// Read the Cargo package name.
76    pub fn name(self, text: &str) -> Result<String> {
77        TomlFormat.read_text(text, "/package/name")
78    }
79
80    /// Read the package version, or `None` when inherited from the workspace.
81    pub fn version(self, text: &str) -> Result<Option<String>> {
82        match TomlFormat.read_text(text, "/package/version") {
83            Ok(version) => Ok(Some(version)),
84            Err(_) if cargo_version_is_inherited(text)? => Ok(None),
85            Err(error) => Err(error),
86        }
87    }
88
89    /// Replace the package version. Inherited versions must be written at the workspace root.
90    pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
91        if cargo_version_is_inherited(text)? {
92            return Err(Error::Validation(
93                "Cargo package inherits workspace version".to_owned(),
94            ));
95        }
96        TomlFormat.edit_text(text, "/package/version", version)
97    }
98
99    /// Replace `[workspace.package].version` in a workspace root manifest.
100    pub fn edit_workspace_version(self, text: &str, version: &str) -> Result<String> {
101        TomlFormat.edit_text(text, "/workspace/package/version", version)
102    }
103
104    /// Rewrite a Cargo dependency version in package or workspace dependencies.
105    pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
106        let mut document = text
107            .parse::<DocumentMut>()
108            .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
109        let found = rewrite_dependency_table(document.get_mut("dependencies"), name, version)?
110            | rewrite_dependency_table(
111                document
112                    .get_mut("workspace")
113                    .and_then(|workspace| workspace.get_mut("dependencies")),
114                name,
115                version,
116            )?;
117        if !found {
118            return Err(Error::Validation(format!(
119                "Cargo manifest does not declare internal dependency {name}"
120            )));
121        }
122        Ok(document.to_string())
123    }
124
125    /// Whether a dependency is inherited through `{ workspace = true }`.
126    pub fn dependency_is_inherited(self, text: &str, name: &str) -> Result<bool> {
127        let document = text
128            .parse::<DocumentMut>()
129            .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
130        let dependency = document
131            .get("dependencies")
132            .and_then(|dependencies| dependencies.get(name));
133        Ok(dependency.is_some_and(item_workspace_true))
134    }
135}
136
137impl PubAdapter {
138    /// Read the Pub package name.
139    pub fn name(self, text: &str) -> Result<String> {
140        YamlFormat.read_text(text, "/name")
141    }
142
143    /// Read the Pub package version.
144    pub fn version(self, text: &str) -> Result<String> {
145        YamlFormat.read_text(text, "/version")
146    }
147
148    /// Replace the Pub package version.
149    pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
150        YamlFormat.edit_text(text, "/version", version)
151    }
152
153    /// Rewrite a direct Pub dependency constraint.
154    pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
155        YamlFormat.edit_text(
156            text,
157            &format!("/dependencies/{name}"),
158            &format!("^{version}"),
159        )
160    }
161}
162
163impl PythonAdapter {
164    /// Read the PEP 621 project name.
165    pub fn name(self, text: &str) -> Result<String> {
166        TomlFormat.read_text(text, "/project/name")
167    }
168
169    /// Read the PEP 621 project version.
170    pub fn version(self, text: &str) -> Result<String> {
171        TomlFormat.read_text(text, "/project/version")
172    }
173
174    /// Replace the PEP 621 project version.
175    pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
176        TomlFormat.edit_text(text, "/project/version", version)
177    }
178
179    /// Rewrite a PEP 508 dependency string in `project.dependencies`.
180    pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
181        let mut document = text
182            .parse::<DocumentMut>()
183            .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
184        let array = document
185            .get_mut("project")
186            .and_then(|project| project.get_mut("dependencies"))
187            .and_then(Item::as_array_mut)
188            .ok_or_else(|| {
189                Error::Validation("Python project.dependencies must be an array".to_owned())
190            })?;
191        let mut found = false;
192        for dependency in array.iter_mut() {
193            let Some(existing) = dependency.as_str() else {
194                continue;
195            };
196            if python_dependency_name(existing) != name {
197                continue;
198            }
199            let decor = dependency.decor().clone();
200            *dependency = Value::from(format!("{name}>={version}"));
201            *dependency.decor_mut() = decor;
202            found = true;
203        }
204        if !found {
205            return Err(Error::Validation(format!(
206                "Python manifest does not declare internal dependency {name}"
207            )));
208        }
209        Ok(document.to_string())
210    }
211}
212
213impl MsbuildAdapter {
214    /// Read `<PackageId>` when present.
215    pub fn name(self, text: &str) -> Result<String> {
216        read_xml_element(text, "PackageId")
217    }
218
219    /// Read the `<Version>` element.
220    pub fn version(self, text: &str) -> Result<String> {
221        read_xml_element(text, "Version")
222    }
223
224    /// Replace only the contents of the first `<Version>` element.
225    pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
226        edit_xml_element(text, "Version", version)
227    }
228
229    /// Rewrite a `PackageReference` version attribute or nested element.
230    pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
231        let needle = format!("Include=\"{name}\"");
232        let include = text.find(&needle).ok_or_else(|| {
233            Error::Validation(format!(
234                "MSBuild project does not declare PackageReference {name}"
235            ))
236        })?;
237        let tag_start = text[..include]
238            .rfind('<')
239            .ok_or_else(|| Error::Validation("invalid MSBuild PackageReference".to_owned()))?;
240        let tag_end = text[include..]
241            .find('>')
242            .map(|offset| include + offset)
243            .ok_or_else(|| Error::Validation("unterminated PackageReference".to_owned()))?;
244        let opening = &text[tag_start..=tag_end];
245        if let Some(version_attribute) = opening.find("Version=\"") {
246            let start = tag_start + version_attribute + "Version=\"".len();
247            let end = text[start..]
248                .find('"')
249                .map(|offset| start + offset)
250                .ok_or_else(|| Error::Validation("unterminated Version attribute".to_owned()))?;
251            let mut edited = text.to_owned();
252            edited.replace_range(start..end, version);
253            return Ok(edited);
254        }
255        let close = text[tag_end + 1..]
256            .find("</PackageReference>")
257            .map(|offset| tag_end + 1 + offset)
258            .ok_or_else(|| Error::Validation(format!("PackageReference {name} has no version")))?;
259        let body = &text[tag_end + 1..close];
260        let body_edited = edit_xml_element(body, "Version", version)?;
261        let mut edited = text.to_owned();
262        edited.replace_range(tag_end + 1..close, &body_edited);
263        Ok(edited)
264    }
265}
266
267impl GoAdapter {
268    /// Read the module path.
269    pub fn name(self, text: &str) -> Result<String> {
270        go_module_line(text)
271            .map(|(_, value)| value.to_owned())
272            .ok_or_else(|| Error::Validation("go.mod has no module directive".to_owned()))
273    }
274
275    /// Rewrite the `/vN` suffix for a major release at version 2 or later.
276    pub fn edit_major_module_path(self, text: &str, version: &str) -> Result<String> {
277        let version = SemverVersion::parse(version)?;
278        if version.major < 2 {
279            return Ok(text.to_owned());
280        }
281        let (range, module) = go_module_line(text)
282            .ok_or_else(|| Error::Validation("go.mod has no module directive".to_owned()))?;
283        let base = module
284            .rsplit_once("/v")
285            .filter(|(_, suffix)| suffix.chars().all(|character| character.is_ascii_digit()))
286            .map(|(base, _)| base)
287            .unwrap_or(module);
288        let mut edited = text.to_owned();
289        edited.replace_range(range, &format!("{base}/v{}", version.major));
290        Ok(edited)
291    }
292
293    /// Rewrite a direct `require` version while preserving the mandatory Go `v` syntax.
294    pub fn edit_dependency(self, text: &str, module: &str, version: &str) -> Result<String> {
295        let mut offset = 0;
296        for line_with_newline in text.split_inclusive('\n') {
297            let line = line_with_newline
298                .strip_suffix('\n')
299                .unwrap_or(line_with_newline);
300            let trimmed = line.trim();
301            let fields = trimmed.split_whitespace().collect::<Vec<_>>();
302            let matches = (fields.len() >= 3 && fields[0] == "require" && fields[1] == module)
303                || (fields.len() >= 2 && fields[0] == module);
304            if matches {
305                let old = fields.last().expect("matched fields are non-empty");
306                let start = offset + line.find(old).expect("field comes from line");
307                let mut edited = text.to_owned();
308                edited.replace_range(start..start + old.len(), &format!("v{version}"));
309                return Ok(edited);
310            }
311            offset += line_with_newline.len();
312        }
313        Err(Error::Validation(format!(
314            "go.mod does not require internal module {module}"
315        )))
316    }
317}
318
319fn escape_json_pointer(value: &str) -> String {
320    value.replace('~', "~0").replace('/', "~1")
321}
322
323fn npm_range(existing: &str, version: &str) -> String {
324    let workspace = existing.starts_with("workspace:");
325    let without_workspace = existing.strip_prefix("workspace:").unwrap_or(existing);
326    let operator = if without_workspace.starts_with('~') {
327        "~"
328    } else if without_workspace.starts_with("^") {
329        "^"
330    } else if without_workspace.starts_with(">=") {
331        ">="
332    } else {
333        ""
334    };
335    format!(
336        "{}{}{}",
337        if workspace { "workspace:" } else { "" },
338        operator,
339        version
340    )
341}
342
343fn cargo_version_is_inherited(text: &str) -> Result<bool> {
344    let document = text
345        .parse::<DocumentMut>()
346        .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
347    Ok(document
348        .get("package")
349        .and_then(|package| package.get("version"))
350        .is_some_and(item_workspace_true))
351}
352
353fn item_workspace_true(item: &Item) -> bool {
354    item.as_inline_table()
355        .and_then(|table| table.get("workspace"))
356        .and_then(Value::as_bool)
357        .unwrap_or(false)
358        || item
359            .as_table()
360            .and_then(|table| table.get("workspace"))
361            .and_then(Item::as_bool)
362            .unwrap_or(false)
363}
364
365fn rewrite_dependency_table(table: Option<&mut Item>, name: &str, version: &str) -> Result<bool> {
366    let Some(table) = table.and_then(Item::as_table_like_mut) else {
367        return Ok(false);
368    };
369    let Some(dependency) = table.get_mut(name) else {
370        return Ok(false);
371    };
372    if item_workspace_true(dependency) {
373        return Ok(false);
374    }
375    match dependency {
376        Item::Value(Value::String(old)) => {
377            let decor = old.decor().clone();
378            *dependency = value(version);
379            if let Some(new) = dependency.as_value_mut() {
380                *new.decor_mut() = decor;
381            }
382        }
383        Item::Value(Value::InlineTable(table)) => {
384            let old = table.get("version").ok_or_else(|| {
385                Error::Validation(format!("Cargo dependency {name} has no version"))
386            })?;
387            let decor = old.decor().clone();
388            let mut new = Value::from(version);
389            *new.decor_mut() = decor;
390            table.insert("version", new);
391        }
392        Item::Table(table) => {
393            let old = table.get("version").ok_or_else(|| {
394                Error::Validation(format!("Cargo dependency {name} has no version"))
395            })?;
396            let decor = old
397                .as_value()
398                .map(|value| value.decor().clone())
399                .unwrap_or_default();
400            table.insert("version", value(version));
401            if let Some(new) = table.get_mut("version").and_then(Item::as_value_mut) {
402                *new.decor_mut() = decor;
403            }
404        }
405        _ => {
406            return Err(Error::Validation(format!(
407                "unsupported Cargo dependency declaration for {name}"
408            )));
409        }
410    }
411    Ok(true)
412}
413
414fn python_dependency_name(value: &str) -> &str {
415    let end = value
416        .char_indices()
417        .find(|(_, character)| matches!(character, '<' | '>' | '=' | '!' | '~' | ';' | '[' | ' '))
418        .map(|(index, _)| index)
419        .unwrap_or(value.len());
420    &value[..end]
421}
422
423fn xml_element_range(text: &str, element: &str) -> Result<std::ops::Range<usize>> {
424    let opening = format!("<{element}>");
425    let closing = format!("</{element}>");
426    let start = text
427        .find(&opening)
428        .map(|index| index + opening.len())
429        .ok_or_else(|| Error::Validation(format!("XML element <{element}> was not found")))?;
430    let end = text[start..]
431        .find(&closing)
432        .map(|index| start + index)
433        .ok_or_else(|| Error::Validation(format!("XML element <{element}> is not closed")))?;
434    Ok(start..end)
435}
436
437fn read_xml_element(text: &str, element: &str) -> Result<String> {
438    let range = xml_element_range(text, element)?;
439    Ok(text[range].trim().to_owned())
440}
441
442fn edit_xml_element(text: &str, element: &str, value: &str) -> Result<String> {
443    let range = xml_element_range(text, element)?;
444    let existing = &text[range.clone()];
445    let leading = existing.len() - existing.trim_start().len();
446    let trailing = existing.len() - existing.trim_end().len();
447    let mut replacement = String::new();
448    replacement.push_str(&existing[..leading]);
449    replacement.push_str(value);
450    replacement.push_str(&existing[existing.len() - trailing..]);
451    let mut edited = text.to_owned();
452    edited.replace_range(range, &replacement);
453    Ok(edited)
454}
455
456fn go_module_line(text: &str) -> Option<(std::ops::Range<usize>, &str)> {
457    let mut offset = 0;
458    for line_with_newline in text.split_inclusive('\n') {
459        let line = line_with_newline
460            .strip_suffix('\n')
461            .unwrap_or(line_with_newline);
462        let trimmed = line.trim();
463        if let Some(module) = trimmed.strip_prefix("module ") {
464            let module = module.trim();
465            let start = offset + line.find(module)?;
466            return Some((start..start + module.len(), module));
467        }
468        offset += line_with_newline.len();
469    }
470    None
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn npm_specialization_preserves_layout_and_range_styles() {
479        let input = include_str!("../../tests/fixtures/ecosystems/package.json");
480        let output = NpmAdapter
481            .edit_version(input, "2.0.0")
482            .and_then(|text| NpmAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
483            .and_then(|text| NpmAdapter.edit_dependency(&text, "sample-peer", "2.0.0"))
484            .expect("npm edits");
485        assert_eq!(NpmAdapter.name(&output).expect("name"), "sample-library");
486        assert_eq!(NpmAdapter.version(&output).expect("version"), "2.0.0");
487        assert!(output.contains("\"sample-dependency\": \"^2.0.0\""));
488        assert!(output.contains("\"sample-peer\": \"workspace:^2.0.0\""));
489    }
490
491    #[test]
492    fn cargo_specialization_preserves_comments_and_dependency_shapes() {
493        let input = include_str!("../../tests/fixtures/ecosystems/Cargo.toml");
494        let output = CargoAdapter
495            .edit_version(input, "2.0.0")
496            .and_then(|text| CargoAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
497            .and_then(|text| CargoAdapter.edit_dependency(&text, "sample-string", "2.0.0"))
498            .and_then(|text| CargoAdapter.edit_dependency(&text, "sample-workspace", "2.0.0"))
499            .expect("Cargo edits");
500        assert_eq!(CargoAdapter.name(&output).expect("name"), "sample-library");
501        assert_eq!(
502            CargoAdapter.version(&output).expect("version"),
503            Some("2.0.0".to_owned())
504        );
505        assert!(output.contains("version = \"2.0.0\" # version comment"));
506        assert!(output.contains("version = \"2.0.0"));
507        assert!(output.contains("# dependency comment"));
508        assert!(output.contains("sample-string = \"2.0.0\""));
509    }
510
511    #[test]
512    fn pub_and_python_edits_are_targeted() {
513        let pubspec = "name: sample-library\nversion: 1.2.3 # keep\ndependencies:\n  sample-dependency: ^1.0.0\n";
514        let pubspec = PubAdapter
515            .edit_version(pubspec, "2.0.0")
516            .and_then(|text| PubAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
517            .expect("Pub edits");
518        assert!(pubspec.contains("version: 2.0.0 # keep"));
519        assert!(pubspec.contains("sample-dependency: ^2.0.0"));
520
521        let pyproject = "[project]\nname = \"sample-library\"\nversion = \"1.2.3\" # keep\ndependencies = [\"sample-dependency>=1.0.0\", \"other>=3\"]\n";
522        let pyproject = PythonAdapter
523            .edit_version(pyproject, "2.0.0")
524            .and_then(|text| PythonAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
525            .expect("Python edits");
526        assert!(pyproject.contains("version = \"2.0.0\" # keep"));
527        assert!(pyproject.contains("sample-dependency>=2.0.0"));
528        assert!(pyproject.contains("other>=3"));
529    }
530
531    #[test]
532    fn msbuild_edit_preserves_xml_layout() {
533        let project = "<Project>\n  <!-- keep -->\n  <PropertyGroup><PackageId>sample-library</PackageId><Version>1.2.3</Version></PropertyGroup>\n  <ItemGroup><PackageReference Include=\"sample-dependency\" Version=\"1.0.0\" /></ItemGroup>\n</Project>\n";
534        let project = MsbuildAdapter
535            .edit_version(project, "2.0.0")
536            .and_then(|text| MsbuildAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
537            .expect("MSBuild edits");
538        assert!(project.contains("<!-- keep -->"));
539        assert!(project.contains("<Version>2.0.0</Version>"));
540        assert!(project.contains("Version=\"2.0.0\""));
541    }
542
543    #[test]
544    fn go_major_rewrites_only_module_path() {
545        let module = "module example.invalid/sample/v2\n\ngo 1.22\n\nrequire example.invalid/dependency v1.0.0\n";
546        let edited = GoAdapter
547            .edit_major_module_path(module, "3.0.0")
548            .expect("Go major edit");
549        assert!(edited.starts_with("module example.invalid/sample/v3\n"));
550        assert!(edited.contains("require example.invalid/dependency v1.0.0"));
551    }
552}