day-cli 0.1.4

Declarative app development API using native UI toolkits
//! Managed-region editing for HarmonyOS's `module.json5`.
//!
//! # Why regions instead of parsing
//!
//! There is no JSON5 parser in this tree, and adding one would be the wrong move anyway: any
//! parse→serialize round-trip DELETES the file's comments. The checked-in `module.json5` explains,
//! in a comment, why `ohos.permission.INTERNET` is required even for a loopback socket — silently
//! deleting a maintainer's note about a non-obvious invariant is not an acceptable side effect of
//! adding a permission.
//!
//! JSON5 has comments, so this uses them as the seam. Day owns the text between
//! `// day:<tag>-begin` and `// day:<tag>-end` and never touches a byte outside it. A scaffold
//! without the markers gets them inserted once (so older projects self-migrate); every build after
//! that is a pure replacement, which makes the writer idempotent by construction.

/// Replace the body between `// day:<tag>-begin` and `// day:<tag>-end`.
///
/// Returns `None` when the markers are absent, so the caller can insert them first.
pub fn replace_region(text: &str, tag: &str, body: &str) -> Option<String> {
    let begin = format!("// day:{tag}-begin");
    let end = format!("// day:{tag}-end");
    let b = text.find(&begin)?;
    let e = text.find(&end)?;
    if e < b {
        return None;
    }
    // Keep the begin marker's whole line and the end marker's indentation.
    let after_begin = text[b..].find('\n').map(|p| b + p + 1)?;
    let end_line_start = text[..e].rfind('\n').map(|p| p + 1).unwrap_or(0);
    let mut out = String::with_capacity(text.len() + body.len());
    out.push_str(&text[..after_begin]);
    out.push_str(body);
    out.push_str(&text[end_line_start..]);
    Some(out)
}

/// Insert an empty managed region just before the closing `]` of the array at `key`.
///
/// The scan tracks bracket depth while skipping string literals and `//` / `/* */` comments, so a
/// `]` inside either cannot be mistaken for the array's end.
pub fn ensure_region(text: &str, key: &str, tag: &str) -> Result<String, String> {
    let begin = format!("// day:{tag}-begin");
    if text.contains(&begin) {
        return Ok(text.to_string());
    }
    let needle = format!("\"{key}\"");
    let k = text
        .find(&needle)
        .ok_or_else(|| format!("module.json5 has no {needle} array"))?;
    let open = text[k..]
        .find('[')
        .map(|p| k + p)
        .ok_or_else(|| format!("{needle} is not an array"))?;
    let close = array_end(text, open + 1).ok_or_else(|| format!("{needle} array is not closed"))?;

    // A trailing comma is needed unless the array is empty or already ends with one (JSON5 allows
    // a trailing comma, so this only has to avoid `}{`-style adjacency).
    let prior = last_significant(&text[open + 1..close]);
    let comma = matches!(prior, Some(c) if c != ',');

    let indent = indent_of(text, close);
    let entry_indent = format!("{indent}  ");
    let mut block = String::new();
    if comma {
        block.push(',');
    }
    block.push('\n');
    block.push_str(&format!(
        "{entry_indent}// day:{tag}-begin — generated by `day build` from [permissions] in \
         Day.toml.\n{entry_indent}// Everything between these markers is rewritten every build; \
         edit Day.toml, not here.\n{entry_indent}// day:{tag}-end\n{indent}"
    ));

    let mut out = String::with_capacity(text.len() + block.len());
    out.push_str(&text[..close]);
    // Drop the whitespace that preceded the `]`, since the block re-establishes it.
    while out.ends_with(' ') || out.ends_with('\n') || out.ends_with('\t') {
        out.pop();
    }
    out.push_str(&block);
    out.push_str(&text[close..]);
    Ok(out)
}

/// The offset of the `]` closing an array whose contents start at `from`.
fn array_end(text: &str, from: usize) -> Option<usize> {
    let b = text.as_bytes();
    let mut i = from;
    let mut depth = 1usize;
    while i < b.len() {
        match b[i] {
            b'"' | b'\'' => {
                let quote = b[i];
                i += 1;
                while i < b.len() && b[i] != quote {
                    if b[i] == b'\\' {
                        i += 1;
                    }
                    i += 1;
                }
            }
            b'/' if i + 1 < b.len() && b[i + 1] == b'/' => {
                while i < b.len() && b[i] != b'\n' {
                    i += 1;
                }
            }
            b'/' if i + 1 < b.len() && b[i + 1] == b'*' => {
                i += 2;
                while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') {
                    i += 1;
                }
                i += 1;
            }
            b'[' => depth += 1,
            b']' => {
                depth -= 1;
                if depth == 0 {
                    return Some(i);
                }
            }
            _ => {}
        }
        i += 1;
    }
    None
}

/// The last character that is not whitespace or part of a comment.
fn last_significant(s: &str) -> Option<char> {
    let mut out = None;
    let b = s.as_bytes();
    let mut i = 0usize;
    while i < b.len() {
        match b[i] {
            b'/' if i + 1 < b.len() && b[i + 1] == b'/' => {
                while i < b.len() && b[i] != b'\n' {
                    i += 1;
                }
            }
            b'/' if i + 1 < b.len() && b[i + 1] == b'*' => {
                i += 2;
                while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') {
                    i += 1;
                }
                i += 1;
            }
            c if !c.is_ascii_whitespace() => out = Some(c as char),
            _ => {}
        }
        i += 1;
    }
    out
}

fn indent_of(text: &str, pos: usize) -> String {
    let line_start = text[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
    text[line_start..pos]
        .chars()
        .take_while(|c| *c == ' ' || *c == '\t')
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    // The scaffold template, not the showcase's copy: `include_str!` may not reach outside this
    // package (see web.rs) — and a fixture the CLI's own writers can edit would drift. Same
    // reasoning as plist.rs's `SHOWCASE` fixture; `day new` copies this file verbatim.
    const MODULE: &str = include_str!("../templates/app/platform/ohos/entry/src/main/module.json5");

    #[test]
    fn inserts_a_region_then_replaces_it() {
        let once = ensure_region(MODULE, "requestPermissions", "permissions").expect("insert");
        assert!(once.contains("// day:permissions-begin"));
        assert!(once.contains("// day:permissions-end"));
        // The hand-managed entry and — crucially — the comment explaining it both survive.
        assert!(once.contains("\"ohos.permission.INTERNET\""));
        assert!(once.contains("Required even for the LOOPBACK dayscript engine socket"));

        // Inserting again is a no-op.
        assert_eq!(
            ensure_region(&once, "requestPermissions", "permissions").unwrap(),
            once
        );

        let filled = replace_region(
            &once,
            "permissions",
            "      { \"name\": \"ohos.permission.CAMERA\" },\n",
        )
        .expect("replace");
        assert!(filled.contains("ohos.permission.CAMERA"));
        assert!(filled.contains("\"ohos.permission.INTERNET\""));

        // Replacing with the same body is byte-identical; replacing with a new body drops the old.
        assert_eq!(
            replace_region(
                &filled,
                "permissions",
                "      { \"name\": \"ohos.permission.CAMERA\" },\n"
            )
            .unwrap(),
            filled
        );
        let changed = replace_region(&filled, "permissions", "").unwrap();
        assert!(!changed.contains("ohos.permission.CAMERA"));
        assert!(changed.contains("\"ohos.permission.INTERNET\""));
    }

    #[test]
    fn replace_reports_missing_markers() {
        assert!(replace_region(MODULE, "permissions", "x").is_none());
    }

    /// A `]` inside a string or a comment must not be mistaken for the array's end.
    #[test]
    fn array_end_skips_strings_and_comments() {
        let s = r#"["a]b", // ] not this
  /* ] nor this */ "c"]after"#;
        let end = array_end(s, 1).expect("end");
        assert_eq!(&s[end..end + 1], "]");
        assert_eq!(&s[end..], "]after");
    }

    #[test]
    fn adds_a_comma_only_when_needed() {
        let with_entries = "{\n  \"requestPermissions\": [\n    { \"name\": \"a\" }\n  ]\n}\n";
        let out = ensure_region(with_entries, "requestPermissions", "permissions").unwrap();
        assert!(
            out.contains("{ \"name\": \"a\" },"),
            "needs a separating comma:\n{out}"
        );

        let empty = "{\n  \"requestPermissions\": [\n  ]\n}\n";
        let out = ensure_region(empty, "requestPermissions", "permissions").unwrap();
        assert!(
            !out.contains("[,"),
            "an empty array must not gain a leading comma:\n{out}"
        );
    }
}