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 parsed_metadata = extract_metadata(
179        raw.package
180            .as_ref()
181            .and_then(|package| package.metadata.as_ref()),
182    )
183    .with_context(|| {
184        format!(
185            "failed to parse feature metadata from `{}`",
186            manifest_path.display()
187        )
188    })?;
189
190    let dependencies = collect_manifest_dependency_info(&raw);
191    let package_name = raw.package.and_then(|package| package.name);
192    let mut metadata_only = parsed_metadata.features.clone();
193    let mut features = BTreeMap::new();
194
195    for (name, entries) in raw.features {
196        if name == "default" {
197            continue;
198        }
199
200        let metadata = metadata_only.remove(&name).unwrap_or_default();
201        let has_metadata = parsed_metadata.features.contains_key(&name);
202        let default_enabled = default_features.contains(&name);
203
204        features.insert(
205            name.clone(),
206            Feature {
207                name,
208                metadata,
209                has_metadata,
210                enables: entries
211                    .into_iter()
212                    .map(|entry| FeatureRef::parse(&entry))
213                    .collect(),
214                default_enabled,
215            },
216        );
217    }
218
219    Ok(FeatureManifest {
220        manifest_path,
221        package_name,
222        metadata_table: parsed_metadata.name,
223        metadata_layout: parsed_metadata.layout,
224        features,
225        metadata_only,
226        default_members,
227        default_features,
228        groups: parsed_metadata.groups,
229        dependencies,
230        lint_overrides: parsed_metadata.lint_overrides,
231        lint_preset: parsed_metadata.lint_preset,
232    })
233}
234
235fn collect_manifest_dependency_info(raw: &RawManifest) -> BTreeMap<String, DependencyInfo> {
236    let mut dependencies = BTreeMap::new();
237
238    for (key, dependency) in &raw.dependencies {
239        dependencies.insert(key.clone(), dependency.to_dependency_info(key));
240    }
241
242    for target in raw.target.values() {
243        for (key, dependency) in &target.dependencies {
244            dependencies.insert(key.clone(), dependency.to_dependency_info(key));
245        }
246    }
247
248    dependencies
249}
250
251/// Adds missing metadata scaffolding to a manifest in place.
252pub fn sync_manifest(path: impl AsRef<Path>, options: &SyncOptions) -> Result<SyncReport> {
253    let path = path.as_ref();
254    let preview = preview_sync_manifest(path, options)?;
255
256    if !options.check_only {
257        if let Some(rewritten) = &preview.rewritten {
258            fs::write(path, rewritten)
259                .with_context(|| format!("failed to write manifest `{}`", path.display()))?;
260        }
261    }
262
263    Ok(preview.report)
264}
265
266/// Computes the synchronization result and rewritten TOML without writing it.
267pub fn preview_sync_manifest(path: impl AsRef<Path>, options: &SyncOptions) -> Result<SyncPreview> {
268    let path = path.as_ref();
269    let contents = fs::read_to_string(path)
270        .with_context(|| format!("failed to read manifest `{}`", path.display()))?;
271    let manifest = parse_manifest_str(&contents, path)?;
272
273    let mut added_features = manifest
274        .features
275        .values()
276        .filter(|feature| !feature.has_metadata)
277        .map(|feature| feature.name.clone())
278        .collect::<Vec<_>>();
279    added_features.sort();
280
281    let mut removed_features = if options.remove_stale {
282        manifest.metadata_only.keys().cloned().collect::<Vec<_>>()
283    } else {
284        Vec::new()
285    };
286    removed_features.sort();
287
288    let metadata_table = manifest
289        .metadata_table
290        .clone()
291        .unwrap_or_else(|| FEATURE_MANIFEST_METADATA_TABLE.to_owned());
292    let style = options.style.unwrap_or(manifest.metadata_layout);
293
294    let would_change = !added_features.is_empty()
295        || !removed_features.is_empty()
296        || options
297            .style
298            .is_some_and(|requested| requested != manifest.metadata_layout);
299
300    let report = SyncReport {
301        manifest_path: path.to_path_buf(),
302        package_name: manifest.package_name.clone(),
303        metadata_table: metadata_table.clone(),
304        style,
305        added_features,
306        removed_features,
307        would_change,
308    };
309
310    if !would_change {
311        return Ok(SyncPreview {
312            report,
313            rewritten: None,
314        });
315    }
316
317    let mut document = contents.parse::<DocumentMut>().with_context(|| {
318        format!(
319            "failed to parse TOML document for synchronization from `{}`",
320            path.display()
321        )
322    })?;
323
324    rewrite_feature_metadata(
325        &mut document,
326        &manifest,
327        &metadata_table,
328        style,
329        &report.added_features,
330        options.remove_stale,
331    )?;
332
333    Ok(SyncPreview {
334        report,
335        rewritten: Some(document.to_string()),
336    })
337}
338
339/// Renders a compact unified diff for a manifest preview.
340pub fn render_sync_diff(path: &Path, before: &str, after: &str) -> String {
341    let path = path.display();
342    let mut output = format!("--- a/{path}\n+++ b/{path}\n");
343    let before_lines = before.lines().collect::<Vec<_>>();
344    let after_lines = after.lines().collect::<Vec<_>>();
345
346    output.push_str(&format!(
347        "@@ -1,{} +1,{} @@\n",
348        before_lines.len(),
349        after_lines.len()
350    ));
351
352    for operation in diff_lines(&before_lines, &after_lines) {
353        let (prefix, line) = match operation {
354            DiffLine::Unchanged(line) => (' ', line),
355            DiffLine::Removed(line) => ('-', line),
356            DiffLine::Added(line) => ('+', line),
357        };
358        output.push(prefix);
359        output.push_str(line);
360        output.push('\n');
361    }
362
363    output
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367enum DiffLine<'a> {
368    Unchanged(&'a str),
369    Removed(&'a str),
370    Added(&'a str),
371}
372
373fn diff_lines<'a>(before: &'a [&'a str], after: &'a [&'a str]) -> Vec<DiffLine<'a>> {
374    let before_len = before.len();
375    let after_len = after.len();
376    let mut lengths = vec![vec![0usize; after_len + 1]; before_len + 1];
377
378    for before_index in (0..before_len).rev() {
379        for after_index in (0..after_len).rev() {
380            lengths[before_index][after_index] = if before[before_index] == after[after_index] {
381                lengths[before_index + 1][after_index + 1] + 1
382            } else {
383                lengths[before_index + 1][after_index].max(lengths[before_index][after_index + 1])
384            };
385        }
386    }
387
388    let mut operations = Vec::new();
389    let mut before_index = 0usize;
390    let mut after_index = 0usize;
391
392    while before_index < before_len || after_index < after_len {
393        if before_index < before_len
394            && after_index < after_len
395            && before[before_index] == after[after_index]
396        {
397            operations.push(DiffLine::Unchanged(before[before_index]));
398            before_index += 1;
399            after_index += 1;
400        } else if after_index < after_len
401            && (before_index == before_len
402                || lengths[before_index][after_index + 1] >= lengths[before_index + 1][after_index])
403        {
404            operations.push(DiffLine::Added(after[after_index]));
405            after_index += 1;
406        } else if before_index < before_len {
407            operations.push(DiffLine::Removed(before[before_index]));
408            before_index += 1;
409        }
410    }
411
412    operations
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
416struct ParsedMetadataTable {
417    features: BTreeMap<String, FeatureMetadata>,
418    groups: Vec<FeatureGroup>,
419    name: Option<String>,
420    layout: MetadataLayout,
421    lint_overrides: BTreeMap<String, LintLevel>,
422    lint_preset: Option<LintPreset>,
423}
424
425fn empty_metadata() -> ParsedMetadataTable {
426    ParsedMetadataTable {
427        features: BTreeMap::new(),
428        groups: Vec::new(),
429        name: None,
430        layout: MetadataLayout::Structured,
431        lint_overrides: BTreeMap::new(),
432        lint_preset: None,
433    }
434}
435
436fn extract_metadata(metadata: Option<&toml::Table>) -> Result<ParsedMetadataTable> {
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(ParsedMetadataTable {
515        features,
516        groups,
517        name: Some(table_name),
518        layout: 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}