Skip to main content

edikt_env/
lib.rs

1//! edikt `.env` / `.properties` format module.
2//!
3//! Flat, string-valued, honest line-level editing only: no grammar, no
4//! interpolation, no quoting semantics, no inline comments. `key=value` /
5//! `key:value` entries, `#`/`!` comments, and blanks round-trip byte-for-byte.
6//! Paths are a single `.key`; edits change only the targeted value or line.
7
8mod comments;
9mod edit;
10mod parser;
11mod project;
12mod syntax;
13
14pub use comments::{emit_commented, emit_commented_with};
15pub use edit::apply;
16pub use parser::Dialect;
17
18// The edikt-core types that appear in this crate's own public API, re-exported
19// so a dependent can call these methods without also taking a direct
20// edikt-core dependency (jhheider/edikt#66). `parse` is aliased because this
21// crate's own `parse` is the document parser.
22pub use edikt_core::{
23    CommentKind, Commented, Document, EditError, Expr, Feature, Step, Value, json,
24    parse as parse_expr,
25};
26use syntax::{Sk, SyntaxNode};
27
28/// Comment kinds this format supports (empty => none); the comment
29/// capability, subsuming the boolean `Feature::Comments`. No inline: a `#`
30/// inside a value is data, not a comment.
31pub const COMMENT_KINDS: &[CommentKind] = &[CommentKind::Head, CommentKind::Foot];
32
33/// Capabilities: comments only. Flat and string-valued: no nesting, arrays,
34/// typed scalars, or sections.
35pub const FEATURES: &[Feature] = &[Feature::Comments];
36
37/// A parse failure.
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39#[error("{msg}")]
40pub struct ParseError {
41    pub msg: String,
42}
43
44/// A parsed `.env` / `.properties` document, backed by a lossless CST.
45pub struct Env {
46    root: SyntaxNode,
47    /// Remembered so an appended key is spelled the way the file spells its
48    /// existing ones; a `key=value` line inside an envspaced file would not
49    /// parse back as the same document.
50    dialect: Dialect,
51}
52
53impl Env {
54    /// Access the underlying syntax tree.
55    pub fn syntax(&self) -> &SyntaxNode {
56        &self.root
57    }
58
59    /// Set the entry `key` to a scalar, format-preserving. If `key` doesn't
60    /// exist, a new `key=value` line is appended.
61    pub fn set(&mut self, key: &str, value: &Value) -> Result<(), EditError> {
62        let text = edit::scalar_string(value)?;
63        match edit::find_entry(&self.root, key) {
64            Some(entry) => {
65                let value_node = entry
66                    .children()
67                    .find(|n| n.kind() == Sk::Value)
68                    .ok_or_else(|| EditError::new("entry has no value slot"))?;
69                let new_root = value_node.replace_with(edit::value_node_green(&text));
70                self.root = SyntaxNode::new_root(new_root);
71            }
72            None => {
73                let mut src = self.to_source();
74                if !src.is_empty() && !src.ends_with('\n') {
75                    src.push('\n');
76                }
77                // An appended key must be spelled the way the rest of the
78                // file is, or the document stops parsing as itself.
79                let sep = match self.dialect {
80                    parser::Dialect::Punctuated => "=",
81                    parser::Dialect::Spaced => " ",
82                };
83                src.push_str(&format!("{key}{sep}{text}\n"));
84                self.root = SyntaxNode::new_root(parser::build(&src, self.dialect));
85            }
86        }
87        Ok(())
88    }
89
90    /// The string value of `key`, or `None`.
91    pub fn value_at(&self, key: &str) -> Option<Value> {
92        edit::find_entry(&self.root, key).map(|e| Value::Str(project::entry_value(&e)))
93    }
94
95    /// Delete `key`, removing its whole line (a missing key is a no-op).
96    pub fn delete(&mut self, key: &str) -> Result<(), EditError> {
97        let root = self.root.clone_for_update();
98        if let Some(entry) = edit::find_entry(&root, key) {
99            entry.detach();
100            self.root = SyntaxNode::new_root(root.green().into_owned());
101        }
102        Ok(())
103    }
104}
105
106/// Parse `.env` / `.properties` source into an [`Env`] document.
107pub fn parse(src: &str) -> Result<Env, ParseError> {
108    parse_with(src, Dialect::Punctuated)
109}
110
111/// Parse space-separated `key value` source (`sshd_config`-shaped) into an
112/// [`Env`] document.
113///
114/// Same flat, string-valued model as `.env`; only the separator differs. Not an
115/// `ssh_config` parser: `Match` / `Host` blocks scope the keys beneath them and
116/// this model is flat, so a file using them is out of scope rather than
117/// half-supported.
118pub fn parse_spaced(src: &str) -> Result<Env, ParseError> {
119    parse_with(src, Dialect::Spaced)
120}
121
122/// Parse with an explicit [`Dialect`].
123pub fn parse_with(src: &str, dialect: Dialect) -> Result<Env, ParseError> {
124    let root = SyntaxNode::new_root(parser::build(src, dialect));
125    let malformed = root
126        .descendants_with_tokens()
127        .filter_map(|e| e.into_token())
128        .any(|t| t.kind() == Sk::Error);
129    if malformed {
130        return Err(ParseError {
131            msg: "invalid: a line is neither a comment nor key=value".to_string(),
132        });
133    }
134    Ok(Env { root, dialect })
135}
136
137impl Document for Env {
138    fn to_source(&self) -> String {
139        edikt_syntax::to_source(&self.root)
140    }
141    fn to_value(&self) -> Value {
142        project::to_value(&self.root)
143    }
144    fn features(&self) -> &'static [Feature] {
145        FEATURES
146    }
147    fn apply(&mut self, expr: &Expr) -> Result<Vec<String>, EditError> {
148        edit::apply(self, expr).map(|()| Vec::new())
149    }
150    fn has_comments(&self) -> bool {
151        self.root
152            .descendants_with_tokens()
153            .filter_map(|e| e.into_token())
154            .any(|t| t.kind() == Sk::Comment)
155    }
156    fn to_commented(&self) -> Option<edikt_core::Commented> {
157        Some(comments::to_commented(&self.root))
158    }
159    fn set_comment(
160        &mut self,
161        path: &[edikt_core::Step],
162        kind: edikt_core::CommentKind,
163        text: &str,
164    ) -> Result<Vec<String>, EditError> {
165        let key = comments::single_key(path)?;
166        let (source, warnings) = comments::set_key_comment(&self.root, key, kind, text)?;
167        self.root = SyntaxNode::new_root(parser::build(&source, self.dialect));
168        Ok(warnings)
169    }
170    fn delete_comment(
171        &mut self,
172        path: &[edikt_core::Step],
173        kind: edikt_core::CommentKind,
174    ) -> Result<(), EditError> {
175        let key = comments::single_key(path)?;
176        let source = comments::delete_key_comment(&self.root, key, kind)?;
177        self.root = SyntaxNode::new_root(parser::build(&source, self.dialect));
178        Ok(())
179    }
180}
181
182/// Emit a value as a flat `.env`: every leaf becomes a `key=value` line, with
183/// nested objects/arrays flattened to dotted keys. Returns the text and warnings.
184/// (The comment-free case of [`emit_commented`].)
185pub fn emit(value: &Value) -> Result<(String, Vec<String>), EditError> {
186    comments::emit_commented(&edikt_core::Commented::from_value(value))
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use edikt_core::eval;
193    use edikt_core::parse as parse_expr;
194
195    const SAMPLE: &str = "# service env\nDATABASE_URL=postgres://localhost/app\nDEBUG = true\nEMPTY=\nWITH_HASH=a#b\n";
196
197    fn q(src: &str, expr: &str) -> Vec<Value> {
198        let v = parse(src).unwrap().to_value();
199        eval(&parse_expr(expr).unwrap(), &v).unwrap()
200    }
201
202    fn edit_src(src: &str, expr: &str) -> String {
203        let mut doc = parse(src).unwrap();
204        apply(&mut doc, &parse_expr(expr).unwrap()).unwrap();
205        doc.to_source()
206    }
207
208    fn cedit(src: &str, expr: &str) -> String {
209        let mut doc = parse(src).unwrap();
210        edikt_core::apply_comment_mutation(&mut doc, &parse_expr(expr).unwrap()).unwrap();
211        doc.to_source()
212    }
213
214    #[test]
215    fn comment_mutation_head_foot_and_inline_refused() {
216        // Head above an entry.
217        assert_eq!(
218            cedit("DATABASE_URL=x\nDEBUG=true\n", ".DEBUG.# = \"verbose\""),
219            "DATABASE_URL=x\n# verbose\nDEBUG=true\n"
220        );
221        // Foot after an entry.
222        assert_eq!(
223            cedit("A=1\nB=2\n", ".B.#.foot = \"end\""),
224            "A=1\nB=2\n# end\n"
225        );
226        // Replace an existing head; delete it.
227        assert_eq!(
228            cedit("# old\nK=v\n", ".K.# |= ascii_upcase"),
229            "# OLD\nK=v\n"
230        );
231        assert_eq!(cedit("# drop\nK=v\n", "del(.K.#)"), "K=v\n");
232        // Inline is refused; `.env` has no inline comments.
233        let mut doc = parse("K=v\n").unwrap();
234        let err = edikt_core::apply_comment_mutation(
235            &mut doc,
236            &parse_expr(".K.#.inline = \"x\"").unwrap(),
237        )
238        .unwrap_err()
239        .to_string();
240        assert!(err.contains("no inline comments"), "got: {err}");
241    }
242
243    #[test]
244    fn roundtrips_byte_identically() {
245        for src in [
246            SAMPLE,
247            "",
248            "KEY=value",
249            "a:1\nb : 2\n",
250            "  spaced = yes  \n# comment\n",
251            "! properties comment\nkey.with.dots=1\n",
252            "A=1\r\nB=2\r\n", // CRLF terminators preserved
253            "A=1\n\nB=2\n",   // a blank line between entries
254            "\n\n",           // blank-only document
255        ] {
256            assert_eq!(parse(src).unwrap().to_source(), src, "round-trip: {src:?}");
257        }
258    }
259
260    #[test]
261    fn projects_flat() {
262        assert_eq!(
263            q(SAMPLE, ".DATABASE_URL"),
264            vec![Value::Str("postgres://localhost/app".into())]
265        );
266        assert_eq!(q(SAMPLE, ".DEBUG"), vec![Value::Str("true".into())]);
267        assert_eq!(q(SAMPLE, ".EMPTY"), vec![Value::Str("".into())]);
268        // No inline-comment parsing: the `#` stays in the value.
269        assert_eq!(q(SAMPLE, ".WITH_HASH"), vec![Value::Str("a#b".into())]);
270    }
271
272    #[test]
273    fn set_preserves_separator_style() {
274        // `DATABASE_URL=...` has no spaces; `DEBUG = true` does. Keep each.
275        assert!(
276            edit_src(SAMPLE, r#".DATABASE_URL = "sqlite://x""#).contains("DATABASE_URL=sqlite://x")
277        );
278        assert!(edit_src(SAMPLE, ".DEBUG = false").contains("DEBUG = false"));
279    }
280
281    #[test]
282    fn del_removes_line_and_keeps_comment() {
283        let out = edit_src(SAMPLE, "del(.DEBUG)");
284        assert!(!out.contains("DEBUG"));
285        assert!(out.contains("# service env"));
286        assert!(out.contains("DATABASE_URL="));
287    }
288
289    #[test]
290    fn del_entries_in_pipeline() {
291        assert_eq!(edit_src("A=1\nB=2\nC=3\n", "del(.A) | del(.B)"), "C=3\n");
292    }
293
294    #[test]
295    fn update_and_add_assign() {
296        assert!(edit_src(SAMPLE, ".DEBUG |= ascii_upcase").contains("DEBUG = TRUE"));
297        assert!(edit_src(SAMPLE, r#".DEBUG += "!""#).contains("DEBUG = true!"));
298    }
299
300    #[test]
301    fn nesting_and_arrays_rejected() {
302        let mut doc = parse(SAMPLE).unwrap();
303        assert!(apply(&mut doc, &parse_expr(".DEBUG = [1]").unwrap()).is_err());
304        assert!(apply(&mut doc, &parse_expr(".a.b = 1").unwrap()).is_err()); // no nesting
305    }
306
307    #[test]
308    fn malformed_line_errors() {
309        assert!(parse("not an entry line\n").is_err());
310    }
311
312    #[test]
313    fn creates_new_key_by_appending() {
314        assert_eq!(edit_src("A=1\n", r#".B = "2""#), "A=1\nB=2\n");
315        // appends even when the file lacks a trailing newline
316        assert_eq!(edit_src("A=1", r#".B = "2""#), "A=1\nB=2\n");
317        // preserves the existing content and comments
318        let out = edit_src(SAMPLE, r#".NEW_FLAG = "on""#);
319        assert!(out.contains("# service env"));
320        assert!(out.ends_with("NEW_FLAG=on\n"));
321    }
322
323    #[test]
324    fn dotted_properties_keys_are_single_keys() {
325        // In `.properties`, `app.name` is one key, addressed with a quoted field.
326        let src = "app.name = edikt\nserver.port: 8080\n";
327        assert_eq!(q(src, r#"."app.name""#), vec![Value::Str("edikt".into())]);
328        assert_eq!(q(src, r#"."server.port""#), vec![Value::Str("8080".into())]);
329        assert!(edit_src(src, r#"."server.port" = "9090""#).contains("server.port: 9090"));
330    }
331
332    // --- comment model (extraction + commented emit) -----------------------
333
334    #[test]
335    fn extracts_head_comments_and_trailing_foot() {
336        let src = "# service env\nDATABASE_URL=x\n# stop here\n";
337        let doc = parse(src).unwrap();
338        let c = doc.to_commented().unwrap();
339        assert_eq!(c.to_value(), doc.to_value(), "shapes must match");
340        let edikt_core::CommentedNode::Object(entries) = &c.node else {
341            panic!("expected object");
342        };
343        assert_eq!(entries[0].1.comments.head, vec!["service env"]);
344        assert_eq!(entries[0].1.comments.foot, vec!["stop here"]);
345    }
346
347    #[test]
348    fn commented_emit_round_trips_and_remaps_inline() {
349        let c = parse(SAMPLE).unwrap().to_commented().unwrap();
350        let (out, warnings) = emit_commented(&c).unwrap();
351        assert!(warnings.is_empty());
352        assert!(out.starts_with("# service env\nDATABASE_URL="));
353        assert_eq!(parse(&out).unwrap().to_commented().unwrap(), c);
354
355        // An inline comment (from a richer format) moves to its own line, and
356        // that remap warns.
357        let mut inline = edikt_core::Commented::from_value(&Value::Object(vec![(
358            "PORT".into(),
359            Value::Str("80".into()),
360        )]));
361        let edikt_core::CommentedNode::Object(entries) = &mut inline.node else {
362            unreachable!();
363        };
364        entries[0].1.comments.inline = Some("the listen port".into());
365        let (out2, warnings2) = emit_commented(&inline).unwrap();
366        assert_eq!(out2, "# the listen port\nPORT=80\n");
367        assert_eq!(warnings2.len(), 1);
368        assert!(
369            warnings2[0].contains("inline comments moved"),
370            "got: {warnings2:?}"
371        );
372    }
373
374    #[test]
375    fn roundtrips_every_fixture() {
376        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/env");
377        let mut count = 0;
378        for entry in std::fs::read_dir(&dir).expect("fixtures/env directory") {
379            let path = entry.unwrap().path();
380            // The dialect is chosen by extension here only because these are
381            // fixtures; real envspaced files (sshd_config) have no extension,
382            // which is exactly why the CLI refuses to auto-detect them.
383            let dialect = match path.extension().and_then(|e| e.to_str()) {
384                Some("env") | Some("properties") => Dialect::Punctuated,
385                Some("envspaced") => Dialect::Spaced,
386                _ => continue,
387            };
388            let src = std::fs::read_to_string(&path).unwrap();
389            assert_eq!(
390                parse_with(&src, dialect).unwrap().to_source(),
391                src,
392                "round-trip must be byte-identical: {}",
393                path.display()
394            );
395            count += 1;
396        }
397        assert!(count >= 2, "expected env fixtures, found {count}");
398    }
399
400    // --- edit dispatch: pipe, del arity, non-assignment ---------------------
401
402    #[test]
403    fn piped_mutations_apply_in_order() {
404        assert_eq!(
405            edit_src("A=1\nB=2\n", r#".A = "x" | .B = "y""#),
406            "A=x\nB=y\n"
407        );
408    }
409
410    #[test]
411    fn del_with_wrong_arity_errors() {
412        // Function args are `;`-separated, so `del(.A; .B)` is two arguments.
413        let mut doc = parse("A=1\nB=2\n").unwrap();
414        let err = apply(&mut doc, &parse_expr("del(.A; .B)").unwrap())
415            .unwrap_err()
416            .to_string();
417        assert!(
418            err.contains("del(...) takes one path argument"),
419            "got: {err}"
420        );
421    }
422
423    #[test]
424    fn a_bare_query_is_not_a_mutation() {
425        let mut doc = parse("A=1\n").unwrap();
426        let err = apply(&mut doc, &parse_expr(".A").unwrap())
427            .unwrap_err()
428            .to_string();
429        assert!(err.contains("expected an assignment"), "got: {err}");
430    }
431
432    // --- comment paths: document-level and nested are refused ---------------
433
434    #[test]
435    fn comment_document_banner_is_a_followup() {
436        let mut doc = parse("A=1\n").unwrap();
437        let err =
438            edikt_core::apply_comment_mutation(&mut doc, &parse_expr(r#".# = "banner""#).unwrap())
439                .unwrap_err()
440                .to_string();
441        assert!(err.contains("document-level"), "got: {err}");
442    }
443
444    #[test]
445    fn nested_comment_path_is_refused() {
446        let mut doc = parse("A=1\n").unwrap();
447        let err =
448            edikt_core::apply_comment_mutation(&mut doc, &parse_expr(r#".a.b.# = "x""#).unwrap())
449                .unwrap_err()
450                .to_string();
451        assert!(
452            err.contains("flat: comment paths are a single"),
453            "got: {err}"
454        );
455    }
456
457    // --- comment deletion: inline and missing-key no-ops --------------------
458
459    #[test]
460    fn deleting_inline_comment_is_a_noop() {
461        // `.env` has no inline comments, so `del(.K.#.inline)` changes nothing.
462        assert_eq!(cedit("# h\nK=v\n", "del(.K.#.inline)"), "# h\nK=v\n");
463    }
464
465    #[test]
466    fn deleting_comment_on_missing_key_is_a_noop() {
467        assert_eq!(cedit("# h\nK=v\n", "del(.NOPE.#)"), "# h\nK=v\n");
468    }
469
470    // --- extraction: trailing comments with no entries ----------------------
471
472    #[test]
473    fn all_comments_no_entries_become_document_foot() {
474        let doc = parse("# just a note\n# and another\n").unwrap();
475        let c = doc.to_commented().unwrap();
476        let edikt_core::CommentedNode::Object(entries) = &c.node else {
477            panic!("expected object");
478        };
479        assert!(entries.is_empty(), "no entries in a comment-only file");
480        assert_eq!(
481            c.comments.foot,
482            vec!["just a note".to_string(), "and another".to_string()]
483        );
484    }
485
486    // --- emission edge cases ------------------------------------------------
487
488    #[test]
489    fn emit_rejects_a_top_level_scalar() {
490        let err = emit(&Value::Str("x".into())).unwrap_err().to_string();
491        assert!(err.contains("requires a top-level object"), "got: {err}");
492    }
493
494    #[test]
495    fn emit_carries_an_entry_foot_comment() {
496        let mut c = edikt_core::Commented::from_value(&Value::Object(vec![(
497            "A".into(),
498            Value::Str("1".into()),
499        )]));
500        let edikt_core::CommentedNode::Object(entries) = &mut c.node else {
501            unreachable!();
502        };
503        entries[0].1.comments.foot.push("tail".into());
504        let (out, warnings) = emit_commented(&c).unwrap();
505        assert_eq!(out, "A=1\n# tail\n");
506        assert!(warnings.is_empty());
507    }
508
509    #[test]
510    fn emit_flattens_nesting_and_warns() {
511        let (out, warnings) = emit(&Value::Object(vec![(
512            "a".into(),
513            Value::Object(vec![("b".into(), Value::Str("1".into()))]),
514        )]))
515        .unwrap();
516        assert_eq!(out, "a.b=1\n");
517        assert_eq!(warnings.len(), 1);
518        assert!(warnings[0].contains("flattened"), "got: {warnings:?}");
519    }
520
521    // --- Document trait surface + syntax accessor ---------------------------
522
523    #[test]
524    fn syntax_accessor_exposes_the_tree() {
525        let doc = parse("A=1\n# c\n").unwrap();
526        assert_eq!(doc.syntax().text().to_string(), "A=1\n# c\n");
527    }
528
529    #[test]
530    fn document_trait_features_comments_and_apply() {
531        let mut doc = parse("# note\nA=1\n").unwrap();
532        assert_eq!(doc.features(), FEATURES);
533        assert!(doc.has_comments());
534        assert!(!parse("A=1\n").unwrap().has_comments());
535        // The trait's `apply` dispatches into the format-preserving edit path.
536        Document::apply(&mut doc, &parse_expr(r#".A = "2""#).unwrap()).unwrap();
537        assert_eq!(doc.to_source(), "# note\nA=2\n");
538    }
539
540    // --- Language mapping invariant -----------------------------------------
541
542    #[test]
543    fn language_kind_mapping_roundtrips() {
544        use rowan::Language;
545        for k in [
546            Sk::Ws,
547            Sk::Newline,
548            Sk::Comment,
549            Sk::Key,
550            Sk::Sep,
551            Sk::ValStr,
552            Sk::Error,
553            Sk::Value,
554            Sk::Entry,
555            Sk::Root,
556        ] {
557            let raw = crate::syntax::EnvLang::kind_to_raw(k);
558            assert_eq!(crate::syntax::EnvLang::kind_from_raw(raw), k);
559        }
560    }
561
562    // ---- the envspaced dialect (edikt-087 BUG-2) ----
563
564    const SSHD: &str = "# managed\nPort 22\nPermitRootLogin\tyes\n\nHostKey    /etc/ssh/k\n";
565
566    #[test]
567    fn envspaced_parses_and_round_trips_byte_identically() {
568        let doc = parse_spaced(SSHD).unwrap();
569        assert_eq!(doc.to_source(), SSHD);
570    }
571
572    #[test]
573    fn envspaced_reads_values_across_separator_spellings() {
574        let doc = parse_spaced(SSHD).unwrap();
575        // A single space, a tab, and a run of spaces all end the key.
576        assert_eq!(doc.value_at("Port"), Some(Value::Str("22".into())));
577        assert_eq!(
578            doc.value_at("PermitRootLogin"),
579            Some(Value::Str("yes".into()))
580        );
581        assert_eq!(
582            doc.value_at("HostKey"),
583            Some(Value::Str("/etc/ssh/k".into()))
584        );
585    }
586
587    #[test]
588    fn envspaced_value_keeps_its_internal_spaces() {
589        // Only the FIRST whitespace run is the separator; the rest is value.
590        let doc = parse_spaced("Subsystem sftp /usr/lib/sftp-server\n").unwrap();
591        assert_eq!(
592            doc.value_at("Subsystem"),
593            Some(Value::Str("sftp /usr/lib/sftp-server".into()))
594        );
595    }
596
597    #[test]
598    fn envspaced_edit_preserves_the_separator_it_found() {
599        // A tab-separated line must stay tab-separated: the separator is not
600        // the edit's business, only the value is.
601        let mut doc = parse_spaced(SSHD).unwrap();
602        doc.set("PermitRootLogin", &Value::Str("no".into()))
603            .unwrap();
604        assert!(doc.to_source().contains("PermitRootLogin\tno"));
605        // and the untouched lines are byte-identical
606        assert!(doc.to_source().contains("HostKey    /etc/ssh/k"));
607        assert!(doc.to_source().contains("# managed"));
608    }
609
610    #[test]
611    fn envspaced_append_uses_a_space_not_an_equals() {
612        // The dialect is remembered on the document: appending `Key=value` into
613        // a spaced file would produce something that no longer parses as one.
614        let mut doc = parse_spaced(SSHD).unwrap();
615        doc.set("MaxAuthTries", &Value::Str("3".into())).unwrap();
616        assert!(doc.to_source().contains("MaxAuthTries 3"));
617        assert!(!doc.to_source().contains("MaxAuthTries="));
618        // and it parses back as the same document
619        assert_eq!(
620            parse_spaced(&doc.to_source())
621                .unwrap()
622                .value_at("MaxAuthTries"),
623            Some(Value::Str("3".into()))
624        );
625    }
626
627    #[test]
628    fn the_two_dialects_do_not_read_each_others_files() {
629        // `PORT=22` under the spaced dialect is one key with no separator, and
630        // `Port 22` under the punctuated one likewise: neither silently
631        // half-parses the other, which is why detection is never guessed.
632        assert!(parse_spaced("PORT=22\n").is_err());
633        assert!(parse("Port 22\n").is_err());
634    }
635
636    #[test]
637    fn envspaced_deletes_a_whole_line() {
638        let mut doc = parse_spaced(SSHD).unwrap();
639        doc.delete("Port").unwrap();
640        assert!(!doc.to_source().contains("Port"));
641        assert!(doc.to_source().contains("HostKey"));
642    }
643}