Skip to main content

nu_command/conversions/into/
semver.rs

1use crate::semver::value::SemverValue;
2use nu_cmd_base::input_handler::{CmdArgument, operate};
3use nu_engine::command_prelude::*;
4use nu_protocol::shell_error::generic::GenericError;
5
6#[derive(Clone)]
7pub struct IntoSemver;
8
9impl Command for IntoSemver {
10    fn name(&self) -> &str {
11        "into semver"
12    }
13
14    fn signature(&self) -> Signature {
15        Signature::build("into semver")
16            .input_output_types(vec![
17                (Type::String, Type::custom("semver")),
18                (Type::custom("semver"), Type::custom("semver")),
19                (Type::record(), Type::custom("semver")),
20                (Type::record(), Type::record()),
21                (Type::table(), Type::table()),
22                (Type::list(Type::String), Type::list(Type::custom("semver"))),
23                (
24                    Type::list(Type::custom("semver")),
25                    Type::list(Type::custom("semver")),
26                ),
27                (Type::table(), Type::list(Type::custom("semver"))),
28                // Relaxed case to support heterogeneous lists
29                (Type::Any, Type::custom("semver")),
30            ])
31            .switch(
32                "loose",
33                "Allow common non-strict prefixes such as v1.2.3, v.1.2.3, v:1.2.3, v-1.2.3, or v_1.2.3",
34                Some('l'),
35            )
36            .rest(
37                "rest",
38                SyntaxShape::CellPath,
39                "For a data structure input, convert data at the given cell paths.",
40            )
41            .category(Category::Conversions)
42    }
43
44    fn description(&self) -> &str {
45        "Convert a value (string, record, or semver) to a semantic version."
46    }
47
48    fn extra_description(&self) -> &str {
49        "From a record: major/minor/patch are required; pre, build, and prefix are optional. \
50         prefix is display-only metadata (e.g. \"v\"); re-parsing from text with --loose only \
51         accepts recognized loose prefixes (v/V with optional . : - _)."
52    }
53
54    fn search_terms(&self) -> Vec<&str> {
55        vec!["version", "convert", "semantic"]
56    }
57
58    fn run(
59        &self,
60        engine_state: &EngineState,
61        stack: &mut Stack,
62        call: &Call,
63        input: PipelineData,
64    ) -> Result<PipelineData, ShellError> {
65        let head = call.head;
66        let cell_paths = call.rest(engine_state, stack, 0)?;
67        let loose = call.has_flag(engine_state, stack, "loose")?;
68        let args = Arguments {
69            cell_paths: (!cell_paths.is_empty()).then_some(cell_paths),
70            loose,
71        };
72        operate(into_semver, args, input, head, engine_state.signals())
73    }
74
75    fn examples(&self) -> Vec<Example<'static>> {
76        vec![
77            Example {
78                description: "Convert a string to a semver value",
79                example: "'1.2.3' | into semver",
80                result: None,
81            },
82            Example {
83                description: "Convert a string with prerelease",
84                example: "'1.2.3-alpha.1+build.2' | into semver",
85                result: None,
86            },
87            Example {
88                description: "Convert a record to a semver value",
89                example: "{major: 1, minor: 2, patch: 3} | into semver",
90                result: None,
91            },
92            Example {
93                description: "Parse a version with a common leading v prefix",
94                example: "'v1.2.3' | into semver --loose",
95                result: None,
96            },
97            Example {
98                description: "Parse versions like v.1.2.3, v:1.2.3, v-1.2.3, or v_1.2.3",
99                example: "['v.1.2.3' 'v:2.0.0' 'v-3.0.0' 'v_4.0.0'] | into semver --loose",
100                result: None,
101            },
102        ]
103    }
104}
105
106struct Arguments {
107    cell_paths: Option<Vec<CellPath>>,
108    loose: bool,
109}
110
111impl CmdArgument for Arguments {
112    fn take_cell_paths(&mut self) -> Option<Vec<CellPath>> {
113        self.cell_paths.take()
114    }
115}
116
117fn into_semver(input: &Value, args: &Arguments, head: Span) -> Value {
118    match input {
119        Value::Custom { val, .. } if val.type_name() == "semver" => input.clone(),
120        Value::String { val, .. } => match SemverValue::parse(val, args.loose) {
121            Ok(version) => Value::custom(Box::new(version), head),
122            Err(_) => {
123                let help = if args.loose {
124                    "expected format: major.minor.patch (e.g. 1.2.3), optionally with a v/V, v., v:, v-, or v_ prefix"
125                } else {
126                    "expected format: major.minor.patch (e.g. 1.2.3); use --loose for prefixes like v1.2.3"
127                };
128                Value::error(
129                    ShellError::Generic(
130                        GenericError::new(
131                            format!("Cannot convert \"{val}\" to a semver"),
132                            "the given string is not a valid semver version",
133                            head,
134                        )
135                        .with_help(help),
136                    ),
137                    head,
138                )
139            }
140        },
141        Value::Record { val, .. } => parse_record_to_semver(val, head),
142        _ => Value::error(
143            ShellError::Generic(GenericError::new(
144                format!("Cannot convert {} to semver", input.get_type()),
145                "expected a string, record, or semver value",
146                head,
147            )),
148            head,
149        ),
150    }
151}
152
153fn parse_record_to_semver(record: &nu_protocol::Record, head: Span) -> Value {
154    let major = record.get("major").and_then(|v| v.as_int().ok());
155    let minor = record.get("minor").and_then(|v| v.as_int().ok());
156    let patch = record.get("patch").and_then(|v| v.as_int().ok());
157
158    let major = match major {
159        Some(v) if v >= 0 => v as u64,
160        _ => {
161            return Value::error(
162                ShellError::Generic(
163                    GenericError::new(
164                        "Cannot convert record to semver",
165                        "missing or invalid 'major' field",
166                        head,
167                    )
168                    .with_help("expected a non-negative integer"),
169                ),
170                head,
171            );
172        }
173    };
174
175    let minor = match minor {
176        Some(v) if v >= 0 => v as u64,
177        _ => {
178            return Value::error(
179                ShellError::Generic(
180                    GenericError::new(
181                        "Cannot convert record to semver",
182                        "missing or invalid 'minor' field",
183                        head,
184                    )
185                    .with_help("expected a non-negative integer"),
186                ),
187                head,
188            );
189        }
190    };
191
192    let patch = match patch {
193        Some(v) if v >= 0 => v as u64,
194        _ => {
195            return Value::error(
196                ShellError::Generic(
197                    GenericError::new(
198                        "Cannot convert record to semver",
199                        "missing or invalid 'patch' field",
200                        head,
201                    )
202                    .with_help("expected a non-negative integer"),
203                ),
204                head,
205            );
206        }
207    };
208
209    let pre = record
210        .get("pre")
211        .and_then(|v| v.as_str().ok())
212        .unwrap_or("");
213
214    let build = record
215        .get("build")
216        .and_then(|v| v.as_str().ok())
217        .unwrap_or("");
218
219    // Display-only; any string is accepted so `into record` → `into semver` round-trips.
220    // Text re-parse via `into semver --loose` only recognizes known loose prefixes.
221    let prefix = record
222        .get("prefix")
223        .and_then(|v| v.as_str().ok())
224        .unwrap_or("")
225        .to_string();
226
227    let pre = match semver::Prerelease::new(pre) {
228        Ok(p) => p,
229        Err(e) => {
230            return Value::error(
231                ShellError::Generic(GenericError::new(
232                    "Cannot convert record to semver",
233                    format!("invalid prerelease: {e}"),
234                    head,
235                )),
236                head,
237            );
238        }
239    };
240
241    let build = match semver::BuildMetadata::new(build) {
242        Ok(b) => b,
243        Err(e) => {
244            return Value::error(
245                ShellError::Generic(GenericError::new(
246                    "Cannot convert record to semver",
247                    format!("invalid build metadata: {e}"),
248                    head,
249                )),
250                head,
251            );
252        }
253    };
254
255    let version = semver::Version {
256        major,
257        minor,
258        patch,
259        pre,
260        build,
261    };
262
263    Value::custom(Box::new(SemverValue::with_prefix(version, prefix)), head)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use nu_protocol::record;
270    use nu_test_support::Result;
271    use nu_test_support::prelude::*;
272
273    fn args(loose: bool) -> Arguments {
274        Arguments {
275            cell_paths: None,
276            loose,
277        }
278    }
279
280    fn get_custom_value(value: &Value) -> &SemverValue {
281        match value {
282            Value::Custom { val, .. } => val.as_any().downcast_ref::<SemverValue>().unwrap(),
283            _ => panic!("Expected Custom value"),
284        }
285    }
286
287    #[test]
288    fn test_into_semver_from_string() {
289        let value = Value::string("1.2.3", Span::test_data());
290        let result = into_semver(&value, &args(false), Span::test_data());
291
292        assert!(matches!(result, Value::Custom { .. }));
293        let semver_val = get_custom_value(&result);
294        assert_eq!(semver_val.version.to_string(), "1.2.3");
295        assert!(semver_val.prefix.is_empty());
296    }
297
298    #[test]
299    fn test_into_semver_from_string_with_prerelease() {
300        let value = Value::string("1.2.3-alpha.1+build.2", Span::test_data());
301        let result = into_semver(&value, &args(false), Span::test_data());
302
303        let semver_val = get_custom_value(&result);
304        assert_eq!(semver_val.version.to_string(), "1.2.3-alpha.1+build.2");
305    }
306
307    #[test]
308    fn test_into_semver_from_invalid_string() {
309        let value = Value::string("not-a-version", Span::test_data());
310        let result = into_semver(&value, &args(false), Span::test_data());
311
312        assert!(matches!(result, Value::Error { .. }));
313    }
314
315    #[test]
316    fn test_into_semver_loose_prefixes() {
317        for (input, prefix, version) in [
318            ("v1.2.3", "v", "1.2.3"),
319            ("V1.2.3", "V", "1.2.3"),
320            ("v.1.2.3", "v.", "1.2.3"),
321            ("v:1.2.3", "v:", "1.2.3"),
322            ("v-1.2.3", "v-", "1.2.3"),
323            ("v_1.2.3", "v_", "1.2.3"),
324            ("v1.2.3-alpha.1+build", "v", "1.2.3-alpha.1+build"),
325        ] {
326            let value = Value::string(input, Span::test_data());
327            let result = into_semver(&value, &args(true), Span::test_data());
328            let semver_val = get_custom_value(&result);
329            assert_eq!(semver_val.prefix, prefix, "input={input}");
330            assert_eq!(semver_val.version.to_string(), version, "input={input}");
331            assert_eq!(semver_val.display(), input);
332        }
333    }
334
335    #[test]
336    fn test_into_semver_loose_required_for_prefix() {
337        let value = Value::string("v1.2.3", Span::test_data());
338        let result = into_semver(&value, &args(false), Span::test_data());
339        assert!(matches!(result, Value::Error { .. }));
340    }
341
342    #[test]
343    fn test_into_semver_from_semver() {
344        let original = SemverValue::new(semver::Version::parse("1.2.3").unwrap());
345        let value = Value::custom(Box::new(original), Span::test_data());
346        let result = into_semver(&value, &args(false), Span::test_data());
347
348        // Should return the same value
349        let semver_val = get_custom_value(&result);
350        assert_eq!(semver_val.version.to_string(), "1.2.3");
351    }
352
353    #[test]
354    fn test_into_semver_from_record_basic() {
355        let record = record! {
356            "major" => Value::int(1, Span::test_data()),
357            "minor" => Value::int(2, Span::test_data()),
358            "patch" => Value::int(3, Span::test_data()),
359        };
360        let value = Value::record(record, Span::test_data());
361        let result = into_semver(&value, &args(false), Span::test_data());
362
363        let semver_val = get_custom_value(&result);
364        assert_eq!(semver_val.version.to_string(), "1.2.3");
365    }
366
367    #[test]
368    fn test_into_semver_from_record_with_prerelease() {
369        let record = record! {
370            "major" => Value::int(1, Span::test_data()),
371            "minor" => Value::int(2, Span::test_data()),
372            "patch" => Value::int(3, Span::test_data()),
373            "pre" => Value::string("alpha.1", Span::test_data()),
374        };
375        let value = Value::record(record, Span::test_data());
376        let result = into_semver(&value, &args(false), Span::test_data());
377
378        let semver_val = get_custom_value(&result);
379        assert_eq!(semver_val.version.to_string(), "1.2.3-alpha.1");
380    }
381
382    #[test]
383    fn test_into_semver_from_record_with_build() {
384        let record = record! {
385            "major" => Value::int(1, Span::test_data()),
386            "minor" => Value::int(2, Span::test_data()),
387            "patch" => Value::int(3, Span::test_data()),
388            "build" => Value::string("build.2", Span::test_data()),
389        };
390        let value = Value::record(record, Span::test_data());
391        let result = into_semver(&value, &args(false), Span::test_data());
392
393        let semver_val = get_custom_value(&result);
394        assert_eq!(semver_val.version.to_string(), "1.2.3+build.2");
395    }
396
397    #[test]
398    fn test_into_semver_from_record_with_both() {
399        let record = record! {
400            "major" => Value::int(1, Span::test_data()),
401            "minor" => Value::int(2, Span::test_data()),
402            "patch" => Value::int(3, Span::test_data()),
403            "pre" => Value::string("alpha", Span::test_data()),
404            "build" => Value::string("build", Span::test_data()),
405        };
406        let value = Value::record(record, Span::test_data());
407        let result = into_semver(&value, &args(false), Span::test_data());
408
409        let semver_val = get_custom_value(&result);
410        assert_eq!(semver_val.version.to_string(), "1.2.3-alpha+build");
411    }
412
413    #[test]
414    fn test_into_semver_from_record_with_prefix() {
415        let record = record! {
416            "major" => Value::int(1, Span::test_data()),
417            "minor" => Value::int(2, Span::test_data()),
418            "patch" => Value::int(3, Span::test_data()),
419            "prefix" => Value::string("v", Span::test_data()),
420        };
421        let value = Value::record(record, Span::test_data());
422        let result = into_semver(&value, &args(false), Span::test_data());
423
424        let semver_val = get_custom_value(&result);
425        assert_eq!(semver_val.prefix, "v");
426        assert_eq!(semver_val.version.to_string(), "1.2.3");
427        assert_eq!(semver_val.display(), "v1.2.3");
428    }
429
430    #[test]
431    fn test_into_semver_record_round_trip_preserves_prefix() -> Result {
432        test()
433            .run("'v1.2.3' | into semver --loose | into record | into semver | to text")
434            .expect_value_eq("v1.2.3")
435    }
436
437    #[test]
438    fn test_into_semver_from_record_missing_major() {
439        let record = record! {
440            "minor" => Value::int(2, Span::test_data()),
441            "patch" => Value::int(3, Span::test_data()),
442        };
443        let value = Value::record(record, Span::test_data());
444        let result = into_semver(&value, &args(false), Span::test_data());
445
446        assert!(matches!(result, Value::Error { .. }));
447    }
448
449    #[test]
450    fn test_into_semver_from_record_negative_major() {
451        let record = record! {
452            "major" => Value::int(-1, Span::test_data()),
453            "minor" => Value::int(2, Span::test_data()),
454            "patch" => Value::int(3, Span::test_data()),
455        };
456        let value = Value::record(record, Span::test_data());
457        let result = into_semver(&value, &args(false), Span::test_data());
458
459        assert!(matches!(result, Value::Error { .. }));
460    }
461
462    #[test]
463    fn test_into_semver_from_unsupported_type() {
464        let value = Value::int(42, Span::test_data());
465        let result = into_semver(&value, &args(false), Span::test_data());
466
467        assert!(matches!(result, Value::Error { .. }));
468    }
469
470    #[test]
471    fn test_into_semver_from_list_of_records() -> Result {
472        let value = Value::test_list(vec![
473            Value::test_record(record! {
474                "major" => Value::test_int(1),
475                "minor" => Value::test_int(2),
476                "patch" => Value::test_int(3),
477            }),
478            Value::test_record(record! {
479                "major" => Value::test_int(0),
480                "minor" => Value::test_int(1),
481                "patch" => Value::test_int(6),
482            }),
483        ]);
484
485        test()
486            .run_with_data("into semver", value)
487            .expect_value_eq(vec!["1.2.3", "0.1.6"])
488    }
489
490    #[test]
491    fn test_into_semver_from_list_of_strings() -> Result {
492        let value = Value::test_list(vec![
493            Value::test_string("3.1.0"),
494            Value::test_string("0.10.5"),
495        ]);
496        test()
497            .run_with_data("into semver", value)
498            .expect_value_eq(vec!["3.1.0", "0.10.5"])
499    }
500
501    #[test]
502    fn test_into_semver_loose_list() -> Result {
503        test()
504            .run("['v1.0.0' 'v.2.0.0' 'v:3.0.0'] | into semver --loose")
505            .expect_value_eq(vec!["v1.0.0", "v.2.0.0", "v:3.0.0"])
506    }
507
508    #[test]
509    fn test_into_semver_at_cell_paths() -> Result {
510        let cell_a = Value::test_record(record! {
511            "major" => Value::test_int(0),
512            "minor" => Value::test_int(10),
513            "patch" => Value::test_int(2),
514        });
515        let value = Value::test_record(record! {
516            "a" => cell_a,
517            "b" => Value::test_string("will not error"),
518            "c" => Value::test_string("5.3.0"),
519        });
520
521        test()
522            .run_with_data("into semver a c | values", value)
523            .expect_value_eq(vec!["0.10.2", "will not error", "5.3.0"])
524    }
525
526    #[test]
527    fn semver_comparison_can_be_or_ed_with_bool() -> Result {
528        test()
529            .run("true or (('1.0.0' | into semver) < ('2.0.0' | into semver))")
530            .expect_value_eq(true)
531    }
532
533    #[test]
534    fn semver_comparison_can_be_and_ed_with_bool() -> Result {
535        test()
536            .run("false and (('1.0.0' | into semver) < ('2.0.0' | into semver))")
537            .expect_value_eq(false)
538    }
539
540    #[test]
541    fn semver_comparison_can_be_bound_with_let() -> Result {
542        test()
543            .run("let x = ('1.0.0' | into semver) < ('2.0.0' | into semver); $x")
544            .expect_value_eq(true)
545    }
546
547    #[test]
548    fn semver_in_range_can_be_or_ed_with_bool() -> Result {
549        test()
550            .run("true or (('1.2.3' | into semver) in ('>=1.0.0' | into semver-range))")
551            .expect_value_eq(true)
552    }
553}
554
555#[cfg(test)]
556mod test {
557    use super::*;
558
559    #[test]
560    fn test_examples() -> nu_test_support::Result {
561        nu_test_support::test().examples(IntoSemver)
562    }
563}