Skip to main content

feature_manifest/
parse.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow, bail};
6use serde::Deserialize;
7use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
8
9use crate::model::{
10    DependencyInfo, Feature, FeatureGroup, FeatureManifest, FeatureMetadata, FeatureRef, LintLevel,
11    LintPreset, MetadataLayout,
12};
13
14/// Canonical table name under `[package.metadata]`.
15pub const FEATURE_MANIFEST_METADATA_TABLE: &str = "feature-manifest";
16
17/// Legacy compatibility table name under `[package.metadata]`.
18pub const FEATURE_DOCS_METADATA_TABLE: &str = "feature-docs";
19
20/// Options controlling how `sync_manifest` rewrites metadata.
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
22pub struct SyncOptions {
23    /// Report drift without writing changes.
24    pub check_only: bool,
25    /// Remove metadata entries for features that no longer exist.
26    pub remove_stale: bool,
27    /// Requested metadata layout for rewrites.
28    pub style: Option<MetadataLayout>,
29}
30
31/// Summary of a manifest synchronization pass.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SyncReport {
34    /// Manifest that was inspected.
35    pub manifest_path: PathBuf,
36    /// Cargo package name, when available.
37    pub package_name: Option<String>,
38    /// Metadata table used for synchronization.
39    pub metadata_table: String,
40    /// Metadata layout selected for the result.
41    pub style: MetadataLayout,
42    /// Feature names that would be added to metadata.
43    pub added_features: Vec<String>,
44    /// Stale metadata entries that would be removed.
45    pub removed_features: Vec<String>,
46    /// Whether synchronization would change the manifest.
47    pub would_change: bool,
48}
49
50impl SyncReport {
51    /// Returns `true` when synchronization would change the manifest.
52    pub fn changed(&self) -> bool {
53        self.would_change
54    }
55}
56
57/// Non-writing result from a manifest synchronization preview.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct SyncPreview {
60    /// Summary of the preview.
61    pub report: SyncReport,
62    /// Rewritten TOML when the preview would change the manifest.
63    pub rewritten: Option<String>,
64}
65
66#[derive(Debug, Clone, Deserialize)]
67#[serde(untagged)]
68enum RawFeatureMetadata {
69    Description(String),
70    Detailed(FeatureMetadata),
71}
72
73impl RawFeatureMetadata {
74    fn into_metadata(self) -> FeatureMetadata {
75        match self {
76            Self::Description(description) => FeatureMetadata {
77                description: Some(description),
78                ..FeatureMetadata::default()
79            },
80            Self::Detailed(metadata) => metadata,
81        }
82    }
83}
84
85#[derive(Debug, Deserialize)]
86struct RawManifest {
87    package: Option<RawPackage>,
88    #[serde(default)]
89    features: BTreeMap<String, Vec<String>>,
90    #[serde(default)]
91    dependencies: BTreeMap<String, RawDependency>,
92    #[serde(default)]
93    target: BTreeMap<String, RawTarget>,
94}
95
96#[derive(Debug, Deserialize)]
97struct RawPackage {
98    name: Option<String>,
99    metadata: Option<toml::Table>,
100}
101
102#[derive(Debug, Deserialize)]
103struct RawTarget {
104    #[serde(default)]
105    dependencies: BTreeMap<String, RawDependency>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
109#[serde(untagged)]
110enum RawDependency {
111    Version(String),
112    Detailed(RawDependencyDetail),
113}
114
115#[derive(Debug, Clone, Deserialize)]
116struct RawDependencyDetail {
117    package: Option<String>,
118    #[serde(default)]
119    workspace: bool,
120    optional: Option<bool>,
121}
122
123impl RawDependency {
124    fn to_dependency_info(&self, key: &str) -> DependencyInfo {
125        match self {
126            Self::Version(version) => {
127                let _ = version;
128                DependencyInfo {
129                    key: key.to_owned(),
130                    package: key.to_owned(),
131                    optional: false,
132                }
133            }
134            Self::Detailed(details) => DependencyInfo {
135                key: key.to_owned(),
136                package: details.package.clone().unwrap_or_else(|| key.to_owned()),
137                optional: details.optional.unwrap_or(details.workspace),
138            },
139        }
140    }
141}
142
143/// Loads and parses a manifest from disk.
144pub fn load_manifest(path: impl AsRef<Path>) -> Result<FeatureManifest> {
145    let path = path.as_ref();
146    let contents = fs::read_to_string(path)
147        .with_context(|| format!("failed to read manifest `{}`", path.display()))?;
148    parse_manifest_str(&contents, path)
149}
150
151/// Parses a manifest from a TOML string and normalizes its feature metadata.
152pub fn parse_manifest_str(
153    manifest_source: &str,
154    manifest_path: impl Into<PathBuf>,
155) -> Result<FeatureManifest> {
156    let manifest_path = manifest_path.into();
157    let raw: RawManifest = toml::from_str(manifest_source).with_context(|| {
158        format!(
159            "failed to parse manifest TOML from `{}`",
160            manifest_path.display()
161        )
162    })?;
163
164    let default_members = raw
165        .features
166        .get("default")
167        .cloned()
168        .unwrap_or_default()
169        .into_iter()
170        .map(|value| FeatureRef::parse(&value))
171        .collect::<Vec<_>>();
172    let default_features = default_members
173        .iter()
174        .filter_map(FeatureRef::local_feature_name)
175        .map(str::to_owned)
176        .collect::<BTreeSet<_>>();
177
178    let (metadata_features, groups, metadata_table, metadata_layout, lint_overrides, lint_preset) =
179        extract_metadata(
180            raw.package
181                .as_ref()
182                .and_then(|package| package.metadata.as_ref()),
183        )
184        .with_context(|| {
185            format!(
186                "failed to parse feature metadata from `{}`",
187                manifest_path.display()
188            )
189        })?;
190
191    let dependencies = collect_manifest_dependency_info(&raw);
192    let package_name = raw.package.and_then(|package| package.name);
193    let mut metadata_only = metadata_features.clone();
194    let mut features = BTreeMap::new();
195
196    for (name, entries) in raw.features {
197        if name == "default" {
198            continue;
199        }
200
201        let metadata = metadata_only.remove(&name).unwrap_or_default();
202        let has_metadata = metadata_features.contains_key(&name);
203        let default_enabled = default_features.contains(&name);
204
205        features.insert(
206            name.clone(),
207            Feature {
208                name,
209                metadata,
210                has_metadata,
211                enables: entries
212                    .into_iter()
213                    .map(|entry| FeatureRef::parse(&entry))
214                    .collect(),
215                default_enabled,
216            },
217        );
218    }
219
220    Ok(FeatureManifest {
221        manifest_path,
222        package_name,
223        metadata_table,
224        metadata_layout,
225        features,
226        metadata_only,
227        default_members,
228        default_features,
229        groups,
230        dependencies,
231        lint_overrides,
232        lint_preset,
233    })
234}
235
236fn collect_manifest_dependency_info(raw: &RawManifest) -> BTreeMap<String, DependencyInfo> {
237    let mut dependencies = BTreeMap::new();
238
239    for (key, dependency) in &raw.dependencies {
240        dependencies.insert(key.clone(), dependency.to_dependency_info(key));
241    }
242
243    for target in raw.target.values() {
244        for (key, dependency) in &target.dependencies {
245            dependencies.insert(key.clone(), dependency.to_dependency_info(key));
246        }
247    }
248
249    dependencies
250}
251
252/// Adds missing metadata scaffolding to a manifest in place.
253pub fn sync_manifest(path: impl AsRef<Path>, options: &SyncOptions) -> Result<SyncReport> {
254    let path = path.as_ref();
255    let preview = preview_sync_manifest(path, options)?;
256
257    if !options.check_only {
258        if let Some(rewritten) = &preview.rewritten {
259            fs::write(path, rewritten)
260                .with_context(|| format!("failed to write manifest `{}`", path.display()))?;
261        }
262    }
263
264    Ok(preview.report)
265}
266
267/// Computes the synchronization result and rewritten TOML without writing it.
268pub fn preview_sync_manifest(path: impl AsRef<Path>, options: &SyncOptions) -> Result<SyncPreview> {
269    let path = path.as_ref();
270    let contents = fs::read_to_string(path)
271        .with_context(|| format!("failed to read manifest `{}`", path.display()))?;
272    let manifest = parse_manifest_str(&contents, path)?;
273
274    let mut added_features = manifest
275        .features
276        .values()
277        .filter(|feature| !feature.has_metadata)
278        .map(|feature| feature.name.clone())
279        .collect::<Vec<_>>();
280    added_features.sort();
281
282    let mut removed_features = if options.remove_stale {
283        manifest.metadata_only.keys().cloned().collect::<Vec<_>>()
284    } else {
285        Vec::new()
286    };
287    removed_features.sort();
288
289    let metadata_table = manifest
290        .metadata_table
291        .clone()
292        .unwrap_or_else(|| FEATURE_MANIFEST_METADATA_TABLE.to_owned());
293    let style = options.style.unwrap_or(manifest.metadata_layout);
294
295    let would_change = !added_features.is_empty()
296        || !removed_features.is_empty()
297        || options
298            .style
299            .is_some_and(|requested| requested != manifest.metadata_layout);
300
301    let report = SyncReport {
302        manifest_path: path.to_path_buf(),
303        package_name: manifest.package_name.clone(),
304        metadata_table: metadata_table.clone(),
305        style,
306        added_features,
307        removed_features,
308        would_change,
309    };
310
311    if !would_change {
312        return Ok(SyncPreview {
313            report,
314            rewritten: None,
315        });
316    }
317
318    let mut document = contents.parse::<DocumentMut>().with_context(|| {
319        format!(
320            "failed to parse TOML document for synchronization from `{}`",
321            path.display()
322        )
323    })?;
324
325    rewrite_feature_metadata(
326        &mut document,
327        &manifest,
328        &metadata_table,
329        style,
330        &report.added_features,
331        options.remove_stale,
332    )?;
333
334    Ok(SyncPreview {
335        report,
336        rewritten: Some(document.to_string()),
337    })
338}
339
340/// Renders a compact unified diff for a manifest preview.
341pub fn render_sync_diff(path: &Path, before: &str, after: &str) -> String {
342    let path = path.display();
343    let mut output = format!("--- a/{path}\n+++ b/{path}\n");
344    let before_lines = before.lines().collect::<Vec<_>>();
345    let after_lines = after.lines().collect::<Vec<_>>();
346
347    output.push_str(&format!(
348        "@@ -1,{} +1,{} @@\n",
349        before_lines.len(),
350        after_lines.len()
351    ));
352
353    for operation in diff_lines(&before_lines, &after_lines) {
354        let (prefix, line) = match operation {
355            DiffLine::Unchanged(line) => (' ', line),
356            DiffLine::Removed(line) => ('-', line),
357            DiffLine::Added(line) => ('+', line),
358        };
359        output.push(prefix);
360        output.push_str(line);
361        output.push('\n');
362    }
363
364    output
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368enum DiffLine<'a> {
369    Unchanged(&'a str),
370    Removed(&'a str),
371    Added(&'a str),
372}
373
374fn diff_lines<'a>(before: &'a [&'a str], after: &'a [&'a str]) -> Vec<DiffLine<'a>> {
375    let before_len = before.len();
376    let after_len = after.len();
377    let mut lengths = vec![vec![0usize; after_len + 1]; before_len + 1];
378
379    for before_index in (0..before_len).rev() {
380        for after_index in (0..after_len).rev() {
381            lengths[before_index][after_index] = if before[before_index] == after[after_index] {
382                lengths[before_index + 1][after_index + 1] + 1
383            } else {
384                lengths[before_index + 1][after_index].max(lengths[before_index][after_index + 1])
385            };
386        }
387    }
388
389    let mut operations = Vec::new();
390    let mut before_index = 0usize;
391    let mut after_index = 0usize;
392
393    while before_index < before_len || after_index < after_len {
394        if before_index < before_len
395            && after_index < after_len
396            && before[before_index] == after[after_index]
397        {
398            operations.push(DiffLine::Unchanged(before[before_index]));
399            before_index += 1;
400            after_index += 1;
401        } else if after_index < after_len
402            && (before_index == before_len
403                || lengths[before_index][after_index + 1] >= lengths[before_index + 1][after_index])
404        {
405            operations.push(DiffLine::Added(after[after_index]));
406            after_index += 1;
407        } else if before_index < before_len {
408            operations.push(DiffLine::Removed(before[before_index]));
409            before_index += 1;
410        }
411    }
412
413    operations
414}
415
416type ExtractedMetadata = (
417    BTreeMap<String, FeatureMetadata>,
418    Vec<FeatureGroup>,
419    Option<String>,
420    MetadataLayout,
421    BTreeMap<String, LintLevel>,
422    Option<LintPreset>,
423);
424
425fn empty_metadata() -> ExtractedMetadata {
426    (
427        BTreeMap::new(),
428        Vec::new(),
429        None,
430        MetadataLayout::Structured,
431        BTreeMap::new(),
432        None,
433    )
434}
435
436fn extract_metadata(metadata: Option<&toml::Table>) -> Result<ExtractedMetadata> {
437    let Some(metadata) = metadata else {
438        return Ok(empty_metadata());
439    };
440
441    let (table_name, table_value) =
442        if let Some(value) = metadata.get(FEATURE_MANIFEST_METADATA_TABLE) {
443            (FEATURE_MANIFEST_METADATA_TABLE.to_owned(), value)
444        } else if let Some(value) = metadata.get(FEATURE_DOCS_METADATA_TABLE) {
445            (FEATURE_DOCS_METADATA_TABLE.to_owned(), value)
446        } else {
447            return Ok(empty_metadata());
448        };
449
450    let table = table_value.as_table().ok_or_else(|| {
451        anyhow!("`[package.metadata.{table_name}]` must be a TOML table, not a scalar value")
452    })?;
453
454    let metadata_layout = if table
455        .get("features")
456        .and_then(|item| item.as_table())
457        .is_some()
458    {
459        MetadataLayout::Structured
460    } else if table.iter().any(|(name, _)| {
461        name != "groups" && name != "features" && name != "lints" && name != "preset"
462    }) {
463        MetadataLayout::Flat
464    } else {
465        MetadataLayout::Structured
466    };
467
468    let mut features = BTreeMap::new();
469
470    if let Some(structured_features) = table.get("features") {
471        let structured_features = structured_features.as_table().ok_or_else(|| {
472            anyhow!("`[package.metadata.{table_name}.features]` must be a TOML table")
473        })?;
474
475        for (name, value) in structured_features {
476            insert_feature_metadata(&mut features, name, value, &table_name)?;
477        }
478    }
479
480    for (name, value) in table {
481        if name == "features" || name == "groups" || name == "lints" || name == "preset" {
482            continue;
483        }
484
485        insert_feature_metadata(&mut features, name, value, &table_name)?;
486    }
487
488    let groups = match table.get("groups") {
489        Some(groups) => groups
490            .clone()
491            .try_into()
492            .context("`groups` must be an array of tables")?,
493        None => Vec::new(),
494    };
495
496    let lint_overrides = match table.get("lints") {
497        Some(lints) => lints
498            .clone()
499            .try_into()
500            .context("`lints` must be a table of lint names to levels")?,
501        None => BTreeMap::new(),
502    };
503
504    let lint_preset = match table.get("preset") {
505        Some(preset) => Some(
506            preset
507                .as_str()
508                .ok_or_else(|| anyhow!("`preset` must be a string"))?
509                .parse()?,
510        ),
511        None => None,
512    };
513
514    Ok((
515        features,
516        groups,
517        Some(table_name),
518        metadata_layout,
519        lint_overrides,
520        lint_preset,
521    ))
522}
523
524fn insert_feature_metadata(
525    features: &mut BTreeMap<String, FeatureMetadata>,
526    name: &str,
527    value: &toml::Value,
528    table_name: &str,
529) -> Result<()> {
530    let raw_metadata: RawFeatureMetadata = value.clone().try_into().with_context(|| {
531        format!("feature `{name}` in `[package.metadata.{table_name}]` must be a string or table")
532    })?;
533    let metadata = raw_metadata.into_metadata();
534
535    if features.insert(name.to_owned(), metadata).is_some() {
536        bail!("feature `{name}` is defined more than once in `[package.metadata.{table_name}]`");
537    }
538
539    Ok(())
540}
541
542fn rewrite_feature_metadata(
543    document: &mut DocumentMut,
544    manifest: &FeatureManifest,
545    metadata_table_name: &str,
546    style: MetadataLayout,
547    added_features: &[String],
548    remove_stale: bool,
549) -> Result<()> {
550    let package_table = ensure_child_table(document.as_table_mut(), "package")?;
551    let metadata_parent = ensure_child_table(package_table, "metadata")?;
552    let feature_manifest_table = ensure_child_table(metadata_parent, metadata_table_name)?;
553
554    let mut feature_entries = manifest
555        .features
556        .values()
557        .filter(|feature| feature.has_metadata)
558        .map(|feature| (feature.name.clone(), feature.metadata.clone()))
559        .collect::<BTreeMap<_, _>>();
560
561    if !remove_stale {
562        feature_entries.extend(
563            manifest
564                .metadata_only
565                .iter()
566                .map(|(feature_name, metadata)| (feature_name.clone(), metadata.clone())),
567        );
568    }
569
570    for feature_name in added_features {
571        feature_entries.insert(
572            feature_name.clone(),
573            FeatureMetadata {
574                description: Some(format!("TODO: describe `{feature_name}`.")),
575                ..FeatureMetadata::default()
576            },
577        );
578    }
579
580    remove_existing_feature_metadata(feature_manifest_table)?;
581
582    match style {
583        MetadataLayout::Flat => {
584            feature_manifest_table.remove("features");
585            for (feature_name, metadata) in &feature_entries {
586                feature_manifest_table.insert(
587                    feature_name,
588                    Item::Value(metadata_to_inline_value(metadata, feature_name)),
589                );
590            }
591        }
592        MetadataLayout::Structured => {
593            let features_table = ensure_child_table(feature_manifest_table, "features")?;
594            for (feature_name, metadata) in &feature_entries {
595                features_table.insert(
596                    feature_name,
597                    Item::Value(metadata_to_inline_value(metadata, feature_name)),
598                );
599            }
600        }
601    }
602
603    Ok(())
604}
605
606fn remove_existing_feature_metadata(table: &mut Table) -> Result<()> {
607    let feature_keys = table
608        .iter()
609        .filter_map(|(name, _)| {
610            if name == "groups" || name == "features" || name == "lints" || name == "preset" {
611                None
612            } else {
613                Some(name.to_owned())
614            }
615        })
616        .collect::<Vec<_>>();
617
618    for key in feature_keys {
619        table.remove(&key);
620    }
621
622    if let Some(features_item) = table.get_mut("features") {
623        let features_table = features_item
624            .as_table_mut()
625            .ok_or_else(|| anyhow!("expected `features` to be a TOML table while editing"))?;
626        let nested_keys = features_table
627            .iter()
628            .map(|(name, _)| name.to_owned())
629            .collect::<Vec<_>>();
630        for key in nested_keys {
631            features_table.remove(&key);
632        }
633    }
634
635    Ok(())
636}
637
638fn metadata_to_inline_value(metadata: &FeatureMetadata, feature_name: &str) -> Value {
639    let mut inline = InlineTable::new();
640    inline.insert(
641        "description",
642        Value::from(
643            metadata
644                .description
645                .clone()
646                .unwrap_or_else(|| format!("TODO: describe `{feature_name}`.")),
647        ),
648    );
649
650    if !metadata.public {
651        inline.insert("public", Value::from(false));
652    }
653    if metadata.unstable {
654        inline.insert("unstable", Value::from(true));
655    }
656    if metadata.deprecated {
657        inline.insert("deprecated", Value::from(true));
658    }
659    if metadata.allow_default {
660        inline.insert("allow_default", Value::from(true));
661    }
662    if let Some(note) = &metadata.note {
663        inline.insert("note", Value::from(note.clone()));
664    }
665    if let Some(category) = &metadata.category {
666        inline.insert("category", Value::from(category.clone()));
667    }
668    if let Some(since) = &metadata.since {
669        inline.insert("since", Value::from(since.clone()));
670    }
671    if let Some(docs) = &metadata.docs {
672        inline.insert("docs", Value::from(docs.clone()));
673    }
674    if let Some(tracking_issue) = &metadata.tracking_issue {
675        inline.insert("tracking_issue", Value::from(tracking_issue.clone()));
676    }
677    if !metadata.requires.is_empty() {
678        let mut requires = Array::new();
679        for requirement in &metadata.requires {
680            requires.push(requirement.as_str());
681        }
682        inline.insert("requires", Value::Array(requires));
683    }
684
685    Value::InlineTable(inline)
686}
687
688fn ensure_child_table<'a>(parent: &'a mut Table, key: &str) -> Result<&'a mut Table> {
689    if !parent.contains_key(key) {
690        parent.insert(key, Item::Table(Table::new()));
691    }
692
693    parent[key]
694        .as_table_mut()
695        .ok_or_else(|| anyhow!("expected `{key}` to be a TOML table while editing the manifest"))
696}