Skip to main content

miniextendr_macros/
lifecycle.rs

1//! Lifecycle support for R package deprecation management.
2//!
3//! This module provides integration with R's `lifecycle` package for managing
4//! experimental, deprecated, and superseded functions.
5//!
6//! # Usage
7//!
8//! Mark functions with lifecycle attributes:
9//!
10//! ```rust,ignore
11//! // Using Rust's deprecated attribute
12//! #[deprecated(since = "0.4.0", note = "Use new_fn() instead")]
13//! #[miniextendr]
14//! pub fn old_fn(x: i32) -> i32 { x }
15//!
16//! // Using miniextendr's lifecycle attribute
17//! #[miniextendr(lifecycle = "experimental")]
18//! pub fn new_fn(x: i32) -> i32 { x * 2 }
19//! ```
20//!
21//! # Lifecycle Stages
22//!
23//! - `experimental`: Function is experimental and may change without notice
24//! - `stable`: Function is stable (default, no badge/warning needed)
25//! - `superseded`: Function has a better alternative but will be maintained
26//! - `deprecated`: Function should no longer be used and may be removed
27//! - `defunct`: Function no longer works and throws an error
28
29use std::fmt;
30
31/// Lifecycle stage for a function, method, or argument.
32#[derive(#[automatically_derived]
impl ::core::fmt::Debug for LifecycleStage {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::write_str(f,
            match self {
                LifecycleStage::Experimental => "Experimental",
                LifecycleStage::Stable => "Stable",
                LifecycleStage::Superseded => "Superseded",
                LifecycleStage::SoftDeprecated => "SoftDeprecated",
                LifecycleStage::Deprecated => "Deprecated",
                LifecycleStage::Defunct => "Defunct",
            })
    }
}Debug, #[automatically_derived]
#[doc(hidden)]
unsafe impl ::core::clone::TrivialClone for LifecycleStage { }
#[automatically_derived]
impl ::core::clone::Clone for LifecycleStage {
    #[inline]
    fn clone(&self) -> Self { *self }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for LifecycleStage { }Copy, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for LifecycleStage { }
#[automatically_derived]
impl ::core::cmp::PartialEq for LifecycleStage {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        ::core::intrinsics::discriminant_value(self) ==
            ::core::intrinsics::discriminant_value(other)
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for LifecycleStage { }Eq, #[automatically_derived]
impl ::core::default::Default for LifecycleStage {
    #[inline]
    fn default() -> Self { Self::Stable }
}Default)]
33pub enum LifecycleStage {
34    /// Function is experimental and may change.
35    Experimental,
36    /// Function is stable (no badge/warning needed).
37    #[default]
38    Stable,
39    /// Function has a better alternative but will be maintained.
40    Superseded,
41    /// Function should no longer be used (soft warning first).
42    SoftDeprecated,
43    /// Function should no longer be used (warning).
44    Deprecated,
45    /// Function no longer works (error).
46    Defunct,
47}
48
49impl LifecycleStage {
50    /// Parse a lifecycle stage from a string.
51    ///
52    /// Accepts lowercase stage names. Both `"soft-deprecated"` and `"soft_deprecated"`
53    /// are recognized. Returns `None` for unrecognized strings.
54    pub fn from_str(s: &str) -> Option<Self> {
55        match s.to_lowercase().as_str() {
56            "experimental" => Some(Self::Experimental),
57            "stable" => Some(Self::Stable),
58            "superseded" => Some(Self::Superseded),
59            "soft-deprecated" | "soft_deprecated" => Some(Self::SoftDeprecated),
60            "deprecated" => Some(Self::Deprecated),
61            "defunct" => Some(Self::Defunct),
62            _ => None,
63        }
64    }
65
66    /// Get the inline R roxygen expression for the lifecycle badge.
67    ///
68    /// Returns an R inline code expression like `` `r lifecycle::badge("experimental")` ``
69    /// that roxygen2 evaluates to render a colored badge. Returns `None` for `Stable`
70    /// (no badge needed).
71    pub fn badge(&self) -> Option<&'static str> {
72        match self {
73            Self::Experimental => Some(r#"`r lifecycle::badge("experimental")`"#),
74            Self::Stable => None, // No badge for stable
75            Self::Superseded => Some(r#"`r lifecycle::badge("superseded")`"#),
76            Self::SoftDeprecated | Self::Deprecated => {
77                Some(r#"`r lifecycle::badge("deprecated")`"#)
78            }
79            Self::Defunct => Some(r#"`r lifecycle::badge("deprecated")`"#),
80        }
81    }
82
83    /// Get the fully-qualified lifecycle signal function name.
84    ///
85    /// Returns the R function to call at the start of the wrapper body to emit
86    /// the lifecycle signal (e.g., `"lifecycle::deprecate_warn"`). Returns `None`
87    /// for `Stable` (no signal needed).
88    pub fn signal_fn(&self) -> Option<&'static str> {
89        match self {
90            Self::Experimental => Some("lifecycle::signal_stage"),
91            Self::Stable => None,
92            Self::Superseded => Some("lifecycle::signal_stage"),
93            Self::SoftDeprecated => Some("lifecycle::deprecate_soft"),
94            Self::Deprecated => Some("lifecycle::deprecate_warn"),
95            Self::Defunct => Some("lifecycle::deprecate_stop"),
96        }
97    }
98
99    /// Get the bare R function name for `@importFrom lifecycle` roxygen tag.
100    ///
101    /// Returns the function name without the `lifecycle::` prefix.
102    pub fn import_from_fn(&self) -> Option<&'static str> {
103        match self {
104            Self::Experimental | Self::Superseded => Some("signal_stage"),
105            Self::Stable => None,
106            Self::SoftDeprecated => Some("deprecate_soft"),
107            Self::Deprecated => Some("deprecate_warn"),
108            Self::Defunct => Some("deprecate_stop"),
109        }
110    }
111
112    /// Get the roxygen `@keywords` value, if this stage needs one.
113    ///
114    /// Only `Experimental` adds `@keywords internal` to keep the function
115    /// off the main package index. Returns `None` for all other stages.
116    pub fn keywords(&self) -> Option<&'static str> {
117        match self {
118            Self::Experimental => Some("internal"),
119            Self::Stable => None,
120            Self::Superseded => None,
121            Self::SoftDeprecated | Self::Deprecated | Self::Defunct => None,
122        }
123    }
124}
125
126impl fmt::Display for LifecycleStage {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::Experimental => f.write_fmt(format_args!("experimental"))write!(f, "experimental"),
130            Self::Stable => f.write_fmt(format_args!("stable"))write!(f, "stable"),
131            Self::Superseded => f.write_fmt(format_args!("superseded"))write!(f, "superseded"),
132            Self::SoftDeprecated => f.write_fmt(format_args!("soft-deprecated"))write!(f, "soft-deprecated"),
133            Self::Deprecated => f.write_fmt(format_args!("deprecated"))write!(f, "deprecated"),
134            Self::Defunct => f.write_fmt(format_args!("defunct"))write!(f, "defunct"),
135        }
136    }
137}
138
139/// Full lifecycle specification for a function or method.
140#[derive(#[automatically_derived]
impl ::core::fmt::Debug for LifecycleSpec {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        let names: &'static _ =
            &["stage", "when", "what", "with", "details", "id"];
        let values: &[&dyn ::core::fmt::Debug] =
            &[&self.stage, &self.when, &self.what, &self.with, &self.details,
                        &&self.id];
        ::core::fmt::Formatter::debug_struct_fields_finish(f, "LifecycleSpec",
            names, values)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for LifecycleSpec {
    #[inline]
    fn clone(&self) -> Self {
        LifecycleSpec {
            stage: ::core::clone::Clone::clone(&self.stage),
            when: ::core::clone::Clone::clone(&self.when),
            what: ::core::clone::Clone::clone(&self.what),
            with: ::core::clone::Clone::clone(&self.with),
            details: ::core::clone::Clone::clone(&self.details),
            id: ::core::clone::Clone::clone(&self.id),
        }
    }
}Clone, #[automatically_derived]
impl ::core::default::Default for LifecycleSpec {
    #[inline]
    fn default() -> Self {
        LifecycleSpec {
            stage: ::core::default::Default::default(),
            when: ::core::default::Default::default(),
            what: ::core::default::Default::default(),
            with: ::core::default::Default::default(),
            details: ::core::default::Default::default(),
            id: ::core::default::Default::default(),
        }
    }
}Default)]
141pub struct LifecycleSpec {
142    /// The lifecycle stage.
143    pub stage: LifecycleStage,
144    /// Version when the lifecycle change occurred (e.g., "0.4.0").
145    pub when: Option<String>,
146    /// What is being deprecated (e.g., "old_fn()" or "old_fn(arg)").
147    /// Auto-inferred from function name if not provided.
148    pub what: Option<String>,
149    /// Replacement to suggest (e.g., "new_fn()").
150    pub with: Option<String>,
151    /// Additional details message.
152    pub details: Option<String>,
153    /// Unique ID for lifecycle deprecation tracking.
154    pub id: Option<String>,
155}
156
157impl LifecycleSpec {
158    /// Create a new lifecycle spec with the given stage and no additional metadata.
159    pub fn new(stage: LifecycleStage) -> Self {
160        Self {
161            stage,
162            ..Default::default()
163        }
164    }
165
166    /// Create a lifecycle spec from a Rust `#[deprecated]` attribute.
167    ///
168    /// Maps the `since` field to `when` and attempts to parse the `note` field
169    /// for a "use X instead" pattern to populate `with`. The full note is also
170    /// stored in `details`.
171    pub fn from_deprecated(since: Option<&str>, note: Option<&str>) -> Self {
172        let mut spec = Self::new(LifecycleStage::Deprecated);
173        spec.when = since.map(String::from);
174
175        // Try to parse "with" from note if it contains "use X instead" pattern
176        if let Some(note) = note {
177            if let Some(rest) = note.to_lowercase().strip_prefix("use ") {
178                if let Some(end) = rest.find(" instead") {
179                    spec.with = Some(rest[..end].to_string());
180                } else {
181                    spec.with = Some(rest.to_string());
182                }
183            }
184            spec.details = Some(note.to_string());
185        }
186
187        spec
188    }
189
190    /// Generate the R prelude code for lifecycle signaling.
191    ///
192    /// Returns a single line of R code to insert at the start of the function body,
193    /// or `None` for `Stable` stage. The `fn_name` is used as the `what` argument
194    /// if no explicit `what` was provided.
195    ///
196    /// For experimental/superseded: `lifecycle::signal_stage("stage", "fn_name()")`.
197    /// For deprecated variants: `lifecycle::deprecate_*(when, what, with, details, id)`.
198    pub fn r_prelude(&self, fn_name: &str) -> Option<String> {
199        let signal_fn = self.stage.signal_fn()?;
200
201        // Every argument is an R string literal built from user text.
202        let esc = crate::naming::r_string_escape;
203        let what = esc(self.what.as_deref().unwrap_or(fn_name));
204
205        match self.stage {
206            LifecycleStage::Experimental | LifecycleStage::Superseded => {
207                // lifecycle::signal_stage("experimental", "fn_name()")
208                Some(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}(\"{1}\", \"{2}()\")",
                signal_fn, self.stage, what))
    })format!("{}(\"{}\", \"{}()\")", signal_fn, self.stage, what))
209            }
210            LifecycleStage::SoftDeprecated
211            | LifecycleStage::Deprecated
212            | LifecycleStage::Defunct => {
213                // lifecycle::deprecate_*(when, what, with, details, id)
214                let when = esc(self.when.as_deref().unwrap_or("0.0.0"));
215                let what_arg = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("\"{0}()\"", what))
    })format!("\"{}()\"", what);
216                let with_arg = self
217                    .with
218                    .as_ref()
219                    .map(|w| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!(", \"{0}\"", esc(w)))
    })format!(", \"{}\"", esc(w)))
220                    .unwrap_or_default();
221                let details_arg = self
222                    .details
223                    .as_ref()
224                    .map(|d| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!(", details = \"{0}\"", esc(d)))
    })format!(", details = \"{}\"", esc(d)))
225                    .unwrap_or_default();
226                let id_arg = self
227                    .id
228                    .as_ref()
229                    .map(|id| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!(", id = \"{0}\"", esc(id)))
    })format!(", id = \"{}\"", esc(id)))
230                    .unwrap_or_default();
231
232                Some(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}(\"{1}\", {2}{3}{4}{5})",
                signal_fn, when, what_arg, with_arg, details_arg, id_arg))
    })format!(
233                    "{}(\"{}\", {}{}{}{})",
234                    signal_fn, when, what_arg, with_arg, details_arg, id_arg
235                ))
236            }
237            LifecycleStage::Stable => None,
238        }
239    }
240}
241
242/// Collect the `@importFrom lifecycle ...` roxygen tag needed for a set of lifecycle specs.
243///
244/// This is used by class generators (R6, env, S3, S4, S7) to aggregate lifecycle
245/// imports from all methods and include them in the class-level roxygen block.
246/// Returns `None` if no lifecycle imports are needed.
247pub fn collect_lifecycle_imports<'a>(
248    specs: impl Iterator<Item = &'a LifecycleSpec>,
249) -> Option<String> {
250    let mut fns = std::collections::BTreeSet::new();
251    for spec in specs {
252        if spec.stage.badge().is_some() {
253            fns.insert("badge");
254        }
255        if let Some(fn_name) = spec.stage.import_from_fn() {
256            fns.insert(fn_name);
257        }
258    }
259    if fns.is_empty() {
260        None
261    } else {
262        let mut import = String::from("@importFrom lifecycle");
263        for f in fns {
264            import.push(' ');
265            import.push_str(f);
266        }
267        Some(import)
268    }
269}
270
271/// Parse lifecycle spec from miniextendr attribute arguments.
272///
273/// Supports:
274/// - `lifecycle = "deprecated"` (simple stage)
275/// - `lifecycle(stage = "deprecated", when = "0.4.0", with = "new_fn()")` (full spec)
276pub fn parse_lifecycle_attr(meta: &syn::Meta) -> syn::Result<Option<LifecycleSpec>> {
277    use syn::spanned::Spanned;
278
279    match meta {
280        syn::Meta::NameValue(nv) if nv.path.is_ident("lifecycle") => {
281            // lifecycle = "stage"
282            if let syn::Expr::Lit(syn::ExprLit {
283                lit: syn::Lit::Str(lit),
284                ..
285            }) = &nv.value
286            {
287                let stage = LifecycleStage::from_str(&lit.value()).ok_or_else(|| {
288                    syn::Error::new_spanned(
289                        lit,
290                        "invalid lifecycle stage; expected one of: experimental, stable, superseded, soft-deprecated, deprecated, defunct",
291                    )
292                })?;
293                return Ok(Some(LifecycleSpec::new(stage)));
294            }
295            Err(syn::Error::new_spanned(
296                &nv.value,
297                "lifecycle expects a string literal",
298            ))
299        }
300        syn::Meta::List(list) if list.path.is_ident("lifecycle") => {
301            // lifecycle(stage = "deprecated", when = "0.4.0", ...)
302            let mut spec = LifecycleSpec::default();
303
304            let nested: syn::punctuated::Punctuated<syn::Meta, ::syn::token::Commasyn::Token![,]> =
305                list.parse_args_with(syn::punctuated::Punctuated::parse_terminated)?;
306
307            for meta in nested {
308                if let syn::Meta::NameValue(nv) = meta {
309                    let key = nv.path.get_ident().map(|i| i.to_string());
310                    let value = match &nv.value {
311                        syn::Expr::Lit(expr_lit) => {
312                            if let syn::Lit::Str(lit) = &expr_lit.lit {
313                                lit.value()
314                            } else {
315                                return Err(syn::Error::new_spanned(
316                                    &nv.value,
317                                    "expected string literal",
318                                ));
319                            }
320                        }
321                        _ => {
322                            return Err(syn::Error::new_spanned(
323                                &nv.value,
324                                "expected string literal",
325                            ));
326                        }
327                    };
328
329                    match key.as_deref() {
330                        Some("stage") => {
331                            spec.stage = LifecycleStage::from_str(&value).ok_or_else(|| {
332                                syn::Error::new(nv.value.span(), "invalid lifecycle stage")
333                            })?;
334                        }
335                        Some("when") => spec.when = Some(value),
336                        Some("what") => spec.what = Some(value),
337                        Some("with") => spec.with = Some(value),
338                        Some("details") => spec.details = Some(value),
339                        Some("id") => spec.id = Some(value),
340                        _ => {
341                            return Err(syn::Error::new_spanned(
342                                &nv.path,
343                                "unknown lifecycle option; expected: stage, when, what, with, details, id",
344                            ));
345                        }
346                    }
347                }
348            }
349
350            Ok(Some(spec))
351        }
352        _ => Ok(None),
353    }
354}
355
356/// Extract lifecycle info from a `#[deprecated]` attribute.
357///
358/// Handles all three forms: `#[deprecated]`, `#[deprecated = "msg"]`,
359/// and `#[deprecated(since = "...", note = "...")]`. Returns `None` if the
360/// attribute is not `#[deprecated]`.
361pub fn parse_rust_deprecated(attr: &syn::Attribute) -> Option<LifecycleSpec> {
362    if !attr.path().is_ident("deprecated") {
363        return None;
364    }
365
366    let mut since = None;
367    let mut note = None;
368
369    match &attr.meta {
370        syn::Meta::Path(_) => {
371            // Just #[deprecated]
372        }
373        syn::Meta::NameValue(nv) => {
374            // #[deprecated = "message"]
375            if let syn::Expr::Lit(syn::ExprLit {
376                lit: syn::Lit::Str(lit),
377                ..
378            }) = &nv.value
379            {
380                note = Some(lit.value());
381            }
382        }
383        syn::Meta::List(list) => {
384            // #[deprecated(since = "...", note = "...")]
385            if let Ok(nested) = list.parse_args_with(
386                syn::punctuated::Punctuated::<syn::Meta, ::syn::token::Commasyn::Token![,]>::parse_terminated,
387            ) {
388                for meta in nested {
389                    if let syn::Meta::NameValue(nv) = meta
390                        && let syn::Expr::Lit(syn::ExprLit {
391                            lit: syn::Lit::Str(lit),
392                            ..
393                        }) = &nv.value
394                    {
395                        if nv.path.is_ident("since") {
396                            since = Some(lit.value());
397                        } else if nv.path.is_ident("note") {
398                            note = Some(lit.value());
399                        }
400                    }
401                }
402            }
403        }
404    }
405
406    Some(LifecycleSpec::from_deprecated(
407        since.as_deref(),
408        note.as_deref(),
409    ))
410}
411
412/// Inject lifecycle badge into roxygen tags if not already present.
413///
414/// Modifies the tags in place, prepending the badge to @description if present,
415/// or adding a new @description tag with just the badge.
416pub fn inject_lifecycle_badge(tags: &mut Vec<String>, spec: &LifecycleSpec) {
417    let Some(badge) = spec.stage.badge() else {
418        return;
419    };
420
421    // Check if there's already a description tag
422    let desc_idx = tags.iter().position(|t| t.starts_with("@description"));
423
424    if let Some(idx) = desc_idx {
425        // Prepend badge to existing description
426        let existing = &tags[idx];
427        let desc_content = existing
428            .strip_prefix("@description")
429            .unwrap_or("")
430            .trim_start();
431        tags[idx] = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("@description {0} {1}", badge,
                desc_content))
    })format!("@description {} {}", badge, desc_content);
432    } else {
433        // Insert new description with just the badge at the start
434        tags.insert(0, ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("@description {0}", badge))
    })format!("@description {}", badge));
435    }
436
437    // Add keywords if needed and not present
438    if let Some(keywords) = spec.stage.keywords()
439        && !tags.iter().any(|t| t.starts_with("@keywords"))
440    {
441        tags.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("@keywords {0}", keywords))
    })format!("@keywords {}", keywords));
442    }
443
444    // Add @importFrom for the lifecycle signal function and badge
445    inject_lifecycle_imports(tags, spec);
446}
447
448/// Inject `@importFrom lifecycle` roxygen tags for the signal function and badge.
449///
450/// Adds the necessary `@importFrom` tag so roxygen2 registers the lifecycle
451/// dependency in NAMESPACE. This is only added if not already present.
452pub fn inject_lifecycle_imports(tags: &mut Vec<String>, spec: &LifecycleSpec) {
453    // Collect the lifecycle functions we need to import
454    let mut fns_to_import = Vec::new();
455
456    // The badge inline R code uses lifecycle::badge()
457    if spec.stage.badge().is_some() {
458        fns_to_import.push("badge");
459    }
460
461    // The runtime signal function
462    if let Some(fn_name) = spec.stage.import_from_fn() {
463        fns_to_import.push(fn_name);
464    }
465
466    if fns_to_import.is_empty() {
467        return;
468    }
469
470    // Deduplicate against already-present @importFrom lifecycle tags
471    let already_imported: Vec<&str> = tags
472        .iter()
473        .filter_map(|t| t.strip_prefix("@importFrom lifecycle "))
474        .flat_map(|s| s.split_whitespace())
475        .collect();
476
477    fns_to_import.retain(|f| !already_imported.contains(f));
478
479    if !fns_to_import.is_empty() {
480        tags.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("@importFrom lifecycle {0}",
                fns_to_import.join(" ")))
    })format!("@importFrom lifecycle {}", fns_to_import.join(" ")));
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn test_stage_from_str() {
490        assert_eq!(
491            LifecycleStage::from_str("experimental"),
492            Some(LifecycleStage::Experimental)
493        );
494        assert_eq!(
495            LifecycleStage::from_str("deprecated"),
496            Some(LifecycleStage::Deprecated)
497        );
498        assert_eq!(
499            LifecycleStage::from_str("soft-deprecated"),
500            Some(LifecycleStage::SoftDeprecated)
501        );
502        assert_eq!(LifecycleStage::from_str("unknown"), None);
503    }
504
505    #[test]
506    fn test_r_prelude_deprecated() {
507        let spec = LifecycleSpec {
508            stage: LifecycleStage::Deprecated,
509            when: Some("0.4.0".into()),
510            what: None,
511            with: Some("new_fn()".into()),
512            details: None,
513            id: None,
514        };
515        let prelude = spec.r_prelude("old_fn").unwrap();
516        assert!(prelude.contains("lifecycle::deprecate_warn"));
517        assert!(prelude.contains("0.4.0"));
518        assert!(prelude.contains("old_fn()"));
519        assert!(prelude.contains("new_fn()"));
520    }
521
522    /// User text lands in R string literals: `\` and `"` are escaped, `\` first.
523    #[test]
524    fn test_r_prelude_escapes_string_arguments() {
525        let spec = LifecycleSpec {
526            stage: LifecycleStage::Deprecated,
527            when: Some("0.4.0".into()),
528            what: None,
529            with: Some("new_fn()".into()),
530            details: Some(r#"Use "C:\data" instead."#.into()),
531            id: None,
532        };
533        let prelude = spec.r_prelude("old_fn").unwrap();
534        assert!(
535            prelude.contains(r#"details = "Use \"C:\\data\" instead.""#),
536            "{prelude}"
537        );
538    }
539
540    #[test]
541    fn test_r_prelude_experimental() {
542        let spec = LifecycleSpec::new(LifecycleStage::Experimental);
543        let prelude = spec.r_prelude("my_fn").unwrap();
544        assert!(prelude.contains("lifecycle::signal_stage"));
545        assert!(prelude.contains("experimental"));
546        assert!(prelude.contains("my_fn()"));
547    }
548
549    #[test]
550    fn test_from_deprecated_note() {
551        let spec = LifecycleSpec::from_deprecated(Some("1.0.0"), Some("Use bar() instead"));
552        assert_eq!(spec.stage, LifecycleStage::Deprecated);
553        assert_eq!(spec.when, Some("1.0.0".into()));
554        assert_eq!(spec.with, Some("bar()".into()));
555    }
556
557    #[test]
558    fn test_import_from_fn() {
559        assert_eq!(
560            LifecycleStage::Experimental.import_from_fn(),
561            Some("signal_stage")
562        );
563        assert_eq!(LifecycleStage::Stable.import_from_fn(), None);
564        assert_eq!(
565            LifecycleStage::Superseded.import_from_fn(),
566            Some("signal_stage")
567        );
568        assert_eq!(
569            LifecycleStage::SoftDeprecated.import_from_fn(),
570            Some("deprecate_soft")
571        );
572        assert_eq!(
573            LifecycleStage::Deprecated.import_from_fn(),
574            Some("deprecate_warn")
575        );
576        assert_eq!(
577            LifecycleStage::Defunct.import_from_fn(),
578            Some("deprecate_stop")
579        );
580    }
581
582    #[test]
583    fn test_inject_lifecycle_imports_deprecated() {
584        let spec = LifecycleSpec::new(LifecycleStage::Deprecated);
585        let mut tags = vec!["@title My function".to_string()];
586        inject_lifecycle_badge(&mut tags, &spec);
587        // Should have @importFrom lifecycle badge deprecate_warn
588        let import_tag = tags
589            .iter()
590            .find(|t| t.starts_with("@importFrom lifecycle"))
591            .expect("should have @importFrom lifecycle tag");
592        assert!(import_tag.contains("badge"));
593        assert!(import_tag.contains("deprecate_warn"));
594    }
595
596    #[test]
597    fn test_inject_lifecycle_imports_experimental() {
598        let spec = LifecycleSpec::new(LifecycleStage::Experimental);
599        let mut tags = vec!["@title My function".to_string()];
600        inject_lifecycle_badge(&mut tags, &spec);
601        let import_tag = tags
602            .iter()
603            .find(|t| t.starts_with("@importFrom lifecycle"))
604            .expect("should have @importFrom lifecycle tag");
605        assert!(import_tag.contains("badge"));
606        assert!(import_tag.contains("signal_stage"));
607    }
608
609    #[test]
610    fn test_inject_lifecycle_imports_stable_no_import() {
611        let spec = LifecycleSpec::new(LifecycleStage::Stable);
612        let mut tags = vec!["@title My function".to_string()];
613        inject_lifecycle_badge(&mut tags, &spec);
614        // Stable stage should not add any @importFrom
615        assert!(!tags.iter().any(|t| t.starts_with("@importFrom")));
616    }
617
618    #[test]
619    fn test_inject_lifecycle_imports_no_duplicates() {
620        let spec = LifecycleSpec::new(LifecycleStage::Deprecated);
621        let mut tags = vec![
622            "@title My function".to_string(),
623            "@importFrom lifecycle deprecate_warn badge".to_string(),
624        ];
625        inject_lifecycle_badge(&mut tags, &spec);
626        // Should not add a duplicate @importFrom tag
627        let import_count = tags
628            .iter()
629            .filter(|t| t.starts_with("@importFrom lifecycle"))
630            .count();
631        assert_eq!(import_count, 1);
632    }
633
634    #[test]
635    fn test_collect_lifecycle_imports_mixed_methods() {
636        let specs = [
637            LifecycleSpec::new(LifecycleStage::Deprecated),
638            LifecycleSpec::new(LifecycleStage::Experimental),
639            LifecycleSpec::new(LifecycleStage::Stable),
640        ];
641        let result = collect_lifecycle_imports(specs.iter());
642        let import = result.expect("should produce import tag");
643        // BTreeSet gives sorted order: badge, deprecate_warn, signal_stage
644        assert_eq!(
645            import,
646            "@importFrom lifecycle badge deprecate_warn signal_stage"
647        );
648    }
649
650    #[test]
651    fn test_collect_lifecycle_imports_no_lifecycle() {
652        let specs: Vec<LifecycleSpec> = vec![];
653        let result = collect_lifecycle_imports(specs.iter());
654        assert!(result.is_none());
655    }
656
657    #[test]
658    fn test_collect_lifecycle_imports_only_stable() {
659        let specs = [LifecycleSpec::new(LifecycleStage::Stable)];
660        let result = collect_lifecycle_imports(specs.iter());
661        assert!(result.is_none());
662    }
663}