Skip to main content

llmtxt_core/
lib.rs

1//! llmtxt-core: Portable primitives for the llmtxt content platform.
2//!
3//! This crate is the single source of truth for compression, hashing,
4//! signing, and encoding functions used by both the Rust (SignalDock)
5//! and TypeScript (npm `llmtxt` via WASM) consumers.
6//!
7//! # Features
8//! - `wasm` (default): Enables `wasm-bindgen` exports for JavaScript consumption.
9//!   Disable with `default-features = false` for native-only usage.
10//!
11//! # Native
12//! All functions are available as regular Rust APIs regardless of features.
13
14use flate2::Compression;
15use flate2::read::{ZlibDecoder, ZlibEncoder};
16use hmac::{Hmac, Mac};
17use sha2::{Digest, Sha256};
18use std::io::Read;
19use uuid::Uuid;
20
21#[cfg(feature = "wasm")]
22use wasm_bindgen::prelude::*;
23
24#[cfg(not(target_arch = "wasm32"))]
25mod native_signed_url;
26
27#[cfg(not(target_arch = "wasm32"))]
28pub use native_signed_url::{
29    SignedUrlBuildRequest, SignedUrlParams, VerifyError, generate_signed_url, verify_signed_url,
30};
31
32mod patch;
33pub use patch::{
34    apply_patch, create_patch, reconstruct_version, reconstruct_version_native, squash_patches,
35    squash_patches_native,
36};
37
38type HmacSha256 = Hmac<Sha256>;
39
40const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
41
42// ── Base62 ──────────────────────────────────────────────────────
43
44/// Encode a non-negative integer into a base62 string.
45///
46/// Uses the alphabet `0-9A-Za-z`. Zero encodes to `"0"`.
47#[cfg_attr(feature = "wasm", wasm_bindgen)]
48pub fn encode_base62(mut num: u64) -> String {
49    if num == 0 {
50        return "0".to_string();
51    }
52    let mut result = Vec::new();
53    while num > 0 {
54        result.push(BASE62[(num % 62) as usize]);
55        num /= 62;
56    }
57    result.reverse();
58    String::from_utf8(result).unwrap_or_default()
59}
60
61/// Decode a base62-encoded string back into an integer.
62#[cfg_attr(feature = "wasm", wasm_bindgen)]
63pub fn decode_base62(s: &str) -> u64 {
64    let mut result: u64 = 0;
65    for byte in s.bytes() {
66        let val = match byte {
67            b'0'..=b'9' => byte - b'0',
68            b'A'..=b'Z' => byte - b'A' + 10,
69            b'a'..=b'z' => byte - b'a' + 36,
70            _ => 0,
71        } as u64;
72        result = result * 62 + val;
73    }
74    result
75}
76
77// ── Compression ─────────────────────────────────────────────────
78
79/// Compress a UTF-8 string using zlib-wrapped deflate (RFC 1950).
80///
81/// Matches Node.js `zlib.deflate` output for backward compatibility
82/// with existing stored data.
83///
84/// # Errors
85/// Returns an error string if compression fails.
86#[cfg_attr(feature = "wasm", wasm_bindgen)]
87pub fn compress(data: &str) -> Result<Vec<u8>, String> {
88    let mut encoder = ZlibEncoder::new(data.as_bytes(), Compression::default());
89    let mut compressed = Vec::new();
90    encoder
91        .read_to_end(&mut compressed)
92        .map_err(|e| format!("compression failed: {e}"))?;
93    Ok(compressed)
94}
95
96/// Decompress zlib-wrapped deflate bytes back to a UTF-8 string.
97///
98/// Matches Node.js `zlib.inflate` for backward compatibility.
99///
100/// # Errors
101/// Returns an error string if decompression or UTF-8 conversion fails.
102#[cfg_attr(feature = "wasm", wasm_bindgen)]
103pub fn decompress(data: &[u8]) -> Result<String, String> {
104    let mut decoder = ZlibDecoder::new(data);
105    let mut decompressed = Vec::new();
106    decoder
107        .read_to_end(&mut decompressed)
108        .map_err(|e| format!("decompression failed: {e}"))?;
109    String::from_utf8(decompressed).map_err(|e| format!("invalid UTF-8: {e}"))
110}
111
112// ── ID Generation ───────────────────────────────────────────────
113
114/// Generate an 8-character base62 ID from a UUID v4.
115#[cfg_attr(feature = "wasm", wasm_bindgen)]
116pub fn generate_id() -> String {
117    let uuid = Uuid::new_v4();
118    let hex = uuid.simple().to_string();
119    let hex_prefix = &hex[..16];
120    let num = u64::from_str_radix(hex_prefix, 16).unwrap_or(0);
121    let base62 = encode_base62(num);
122    format!("{:0>8}", &base62[..base62.len().min(8)])
123}
124
125// ── Hashing ─────────────────────────────────────────────────────
126
127/// Compute the SHA-256 hash of a UTF-8 string, returned as lowercase hex.
128#[cfg_attr(feature = "wasm", wasm_bindgen)]
129pub fn hash_content(data: &str) -> String {
130    let mut hasher = Sha256::new();
131    hasher.update(data.as_bytes());
132    hex::encode(hasher.finalize())
133}
134
135// ── Token Estimation ────────────────────────────────────────────
136
137/// Estimate token count using the ~4 chars/token heuristic.
138#[cfg_attr(feature = "wasm", wasm_bindgen)]
139pub fn calculate_tokens(text: &str) -> u32 {
140    let len = text.len() as f64;
141    (len / 4.0).ceil() as u32
142}
143
144// ── Compression Ratio ───────────────────────────────────────────
145
146/// Calculate the compression ratio (original / compressed), rounded to 2 decimals.
147/// Returns 1.0 when `compressed_size` is 0.
148#[cfg_attr(feature = "wasm", wasm_bindgen)]
149pub fn calculate_compression_ratio(original_size: u32, compressed_size: u32) -> f64 {
150    if compressed_size == 0 {
151        return 1.0;
152    }
153    let ratio = original_size as f64 / compressed_size as f64;
154    (ratio * 100.0).round() / 100.0
155}
156
157// ── HMAC Signing ────────────────────────────────────────────────
158
159/// Compute the HMAC-SHA256 signature for signed URL parameters.
160/// Returns the first 16 hex characters of the digest (64 bits).
161/// For longer signatures, use [`compute_signature_with_length`].
162#[cfg_attr(feature = "wasm", wasm_bindgen)]
163pub fn compute_signature(
164    slug: &str,
165    agent_id: &str,
166    conversation_id: &str,
167    expires_at: f64,
168    secret: &str,
169) -> String {
170    compute_signature_with_length(slug, agent_id, conversation_id, expires_at, secret, 16)
171}
172
173/// Compute the HMAC-SHA256 signature with configurable output length.
174///
175/// `sig_length` controls how many hex characters to return (max 64).
176/// Use 16 for short-lived URLs (backward compat), 32 for long-lived URLs (128 bits).
177#[cfg_attr(feature = "wasm", wasm_bindgen)]
178pub fn compute_signature_with_length(
179    slug: &str,
180    agent_id: &str,
181    conversation_id: &str,
182    expires_at: f64,
183    secret: &str,
184    sig_length: usize,
185) -> String {
186    let payload = format!(
187        "{}:{}:{}:{}",
188        slug, agent_id, conversation_id, expires_at as u64
189    );
190    let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
191        return String::new();
192    };
193    mac.update(payload.as_bytes());
194    let result = mac.finalize();
195    let hex_full = hex::encode(result.into_bytes());
196    let len = sig_length.min(64);
197    hex_full[..len].to_string()
198}
199
200/// Compute the HMAC-SHA256 signature for org-scoped signed URL parameters.
201/// Includes `org_id` in the HMAC payload for organization-level access control.
202/// Returns the first 32 hex characters (128 bits) by default.
203#[cfg_attr(feature = "wasm", wasm_bindgen)]
204pub fn compute_org_signature(
205    slug: &str,
206    agent_id: &str,
207    conversation_id: &str,
208    org_id: &str,
209    expires_at: f64,
210    secret: &str,
211) -> String {
212    compute_org_signature_with_length(
213        slug,
214        agent_id,
215        conversation_id,
216        org_id,
217        expires_at,
218        secret,
219        32,
220    )
221}
222
223/// Compute org-scoped HMAC-SHA256 signature with configurable output length.
224#[cfg_attr(feature = "wasm", wasm_bindgen)]
225pub fn compute_org_signature_with_length(
226    slug: &str,
227    agent_id: &str,
228    conversation_id: &str,
229    org_id: &str,
230    expires_at: f64,
231    secret: &str,
232    sig_length: usize,
233) -> String {
234    let payload = format!(
235        "{}:{}:{}:{}:{}",
236        slug, agent_id, conversation_id, org_id, expires_at as u64
237    );
238    let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
239        return String::new();
240    };
241    mac.update(payload.as_bytes());
242    let result = mac.finalize();
243    let hex_full = hex::encode(result.into_bytes());
244    let len = sig_length.min(64);
245    hex_full[..len].to_string()
246}
247
248/// Derive a per-agent signing key from their API key.
249/// Uses `HMAC-SHA256(api_key, "llmtxt-signing")`.
250#[cfg_attr(feature = "wasm", wasm_bindgen)]
251pub fn derive_signing_key(api_key: &str) -> String {
252    let Ok(mut mac) = HmacSha256::new_from_slice(api_key.as_bytes()) else {
253        return String::new();
254    };
255    mac.update(b"llmtxt-signing");
256    hex::encode(mac.finalize().into_bytes())
257}
258
259// ── Expiration ──────────────────────────────────────────────────
260
261/// Check whether a timestamp (milliseconds) has expired.
262/// Returns false for 0 (no expiration).
263///
264/// Uses `js_sys::Date::now()` in WASM, `std::time::SystemTime` natively.
265#[cfg_attr(feature = "wasm", wasm_bindgen)]
266pub fn is_expired(expires_at_ms: f64) -> bool {
267    if expires_at_ms == 0.0 {
268        return false;
269    }
270    let now = current_time_ms();
271    now > expires_at_ms
272}
273
274/// Get current time in milliseconds since epoch.
275/// Uses `js_sys::Date::now()` when compiled to WASM, `SystemTime` natively.
276#[cfg(target_arch = "wasm32")]
277fn current_time_ms() -> f64 {
278    js_sys::Date::now()
279}
280
281/// Get current time in milliseconds since epoch.
282#[cfg(not(target_arch = "wasm32"))]
283fn current_time_ms() -> f64 {
284    std::time::SystemTime::now()
285        .duration_since(std::time::UNIX_EPOCH)
286        .map(|d| d.as_millis() as f64)
287        .unwrap_or(0.0)
288}
289
290// ── Similarity ─────────────────────────────────────────────────
291
292/// Compute character-level n-gram Jaccard similarity between two texts.
293/// Returns 0.0 (no overlap) to 1.0 (identical). Default n=3.
294///
295/// Suitable for finding similar messages without vector embeddings.
296#[cfg_attr(feature = "wasm", wasm_bindgen)]
297pub fn text_similarity(a: &str, b: &str) -> f64 {
298    text_similarity_ngram(a, b, 3)
299}
300
301/// Compute n-gram Jaccard similarity with configurable gram size.
302#[cfg_attr(feature = "wasm", wasm_bindgen)]
303pub fn text_similarity_ngram(a: &str, b: &str, n: usize) -> f64 {
304    let a_lower = a.to_lowercase();
305    let b_lower = b.to_lowercase();
306    let a_norm: String = a_lower.split_whitespace().collect::<Vec<_>>().join(" ");
307    let b_norm: String = b_lower.split_whitespace().collect::<Vec<_>>().join(" ");
308
309    if a_norm.len() < n && b_norm.len() < n {
310        return if a_norm == b_norm { 1.0 } else { 0.0 };
311    }
312
313    let a_grams: std::collections::HashSet<&str> = (0..=a_norm.len().saturating_sub(n))
314        .filter_map(|i| a_norm.get(i..i + n))
315        .collect();
316    let b_grams: std::collections::HashSet<&str> = (0..=b_norm.len().saturating_sub(n))
317        .filter_map(|i| b_norm.get(i..i + n))
318        .collect();
319
320    if a_grams.is_empty() && b_grams.is_empty() {
321        return 1.0;
322    }
323
324    let intersection = a_grams.intersection(&b_grams).count();
325    let union = a_grams.union(&b_grams).count();
326
327    if union == 0 {
328        0.0
329    } else {
330        intersection as f64 / union as f64
331    }
332}
333
334// ── Diff ────────────────────────────────────────────────────────
335
336/// Result of computing a line-based diff between two texts.
337#[cfg_attr(feature = "wasm", wasm_bindgen)]
338#[derive(Debug, Clone)]
339pub struct DiffResult {
340    added_lines: u32,
341    removed_lines: u32,
342    added_tokens: u32,
343    removed_tokens: u32,
344}
345
346#[cfg_attr(feature = "wasm", wasm_bindgen)]
347impl DiffResult {
348    /// Number of lines added in the new text.
349    #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
350    pub fn added_lines(&self) -> u32 {
351        self.added_lines
352    }
353    /// Number of lines removed from the old text.
354    #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
355    pub fn removed_lines(&self) -> u32 {
356        self.removed_lines
357    }
358    /// Estimated tokens added.
359    #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
360    pub fn added_tokens(&self) -> u32 {
361        self.added_tokens
362    }
363    /// Estimated tokens removed.
364    #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
365    pub fn removed_tokens(&self) -> u32 {
366        self.removed_tokens
367    }
368}
369
370/// Compute a line-based diff between two texts.
371///
372/// Uses a hash-based LCS (Longest Common Subsequence) approach for
373/// O(n*m) comparison where n and m are line counts. Returns counts
374/// of added/removed lines and estimated token impact.
375#[cfg_attr(feature = "wasm", wasm_bindgen)]
376pub fn compute_diff(old_text: &str, new_text: &str) -> DiffResult {
377    let old_lines: Vec<&str> = old_text.lines().collect();
378    let new_lines: Vec<&str> = new_text.lines().collect();
379
380    let n = old_lines.len();
381    let m = new_lines.len();
382
383    // Build LCS table
384    let mut dp = vec![vec![0u32; m + 1]; n + 1];
385    for i in 1..=n {
386        for j in 1..=m {
387            if old_lines[i - 1] == new_lines[j - 1] {
388                dp[i][j] = dp[i - 1][j - 1] + 1;
389            } else {
390                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
391            }
392        }
393    }
394
395    // Backtrack to find which lines were removed and which were added
396    let mut removed = Vec::new();
397    let mut added = Vec::new();
398    let mut i = n;
399    let mut j = m;
400
401    while i > 0 || j > 0 {
402        if i > 0 && j > 0 && old_lines[i - 1] == new_lines[j - 1] {
403            i -= 1;
404            j -= 1;
405        } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
406            added.push(new_lines[j - 1]);
407            j -= 1;
408        } else {
409            removed.push(old_lines[i - 1]);
410            i -= 1;
411        }
412    }
413
414    let added_tokens: u32 = added.iter().map(|l| calculate_tokens(l)).sum();
415    let removed_tokens: u32 = removed.iter().map(|l| calculate_tokens(l)).sum();
416
417    DiffResult {
418        added_lines: added.len() as u32,
419        removed_lines: removed.len() as u32,
420        added_tokens,
421        removed_tokens,
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn test_base62_encode() {
431        assert_eq!(encode_base62(0), "0");
432        assert_eq!(encode_base62(1), "1");
433        assert_eq!(encode_base62(61), "z");
434        assert_eq!(encode_base62(62), "10");
435        assert_eq!(encode_base62(3844), "100");
436    }
437
438    #[test]
439    fn test_base62_decode() {
440        assert_eq!(decode_base62("0"), 0);
441        assert_eq!(decode_base62("z"), 61);
442        assert_eq!(decode_base62("10"), 62);
443        assert_eq!(decode_base62("100"), 3844);
444    }
445
446    #[test]
447    fn test_base62_roundtrip() {
448        for n in [0, 1, 42, 61, 62, 100, 3844, 999_999, u64::MAX / 2] {
449            assert_eq!(
450                decode_base62(&encode_base62(n)),
451                n,
452                "roundtrip failed for {n}"
453            );
454        }
455    }
456
457    #[test]
458    fn test_hash_content() {
459        assert_eq!(
460            hash_content("hello"),
461            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
462        );
463        assert_eq!(
464            hash_content(""),
465            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
466        );
467    }
468
469    #[test]
470    fn test_calculate_tokens() {
471        assert_eq!(calculate_tokens("Hello, world!"), 4);
472        assert_eq!(calculate_tokens(""), 0);
473        assert_eq!(calculate_tokens("a"), 1);
474        assert_eq!(calculate_tokens("1234"), 1);
475        assert_eq!(calculate_tokens("12345"), 2);
476    }
477
478    #[test]
479    fn test_compression_ratio() {
480        assert_eq!(calculate_compression_ratio(1000, 400), 2.5);
481        assert_eq!(calculate_compression_ratio(100, 100), 1.0);
482        assert_eq!(calculate_compression_ratio(100, 0), 1.0);
483        assert_eq!(calculate_compression_ratio(500, 200), 2.5);
484    }
485
486    #[test]
487    fn test_compress_decompress_roundtrip() {
488        let input = "Hello, world! This is a test of the llmtxt compression.";
489        let compressed = compress(input).expect("compress should succeed");
490        let decompressed = decompress(&compressed).expect("decompress should succeed");
491        assert_eq!(decompressed, input);
492    }
493
494    #[test]
495    fn test_compress_empty() {
496        let compressed = compress("").expect("compress empty should succeed");
497        let decompressed = decompress(&compressed).expect("decompress should succeed");
498        assert_eq!(decompressed, "");
499    }
500
501    #[test]
502    fn test_compute_signature() {
503        let sig = compute_signature(
504            "xK9mP2nQ",
505            "test-agent",
506            "conv_123",
507            1_700_000_000_000.0,
508            "test-secret",
509        );
510        assert_eq!(sig, "650eb9dd6c396a45");
511    }
512
513    #[test]
514    fn test_compute_signature_with_length() {
515        let sig16 = compute_signature_with_length(
516            "xK9mP2nQ",
517            "test-agent",
518            "conv_123",
519            1_700_000_000_000.0,
520            "test-secret",
521            16,
522        );
523        let sig32 = compute_signature_with_length(
524            "xK9mP2nQ",
525            "test-agent",
526            "conv_123",
527            1_700_000_000_000.0,
528            "test-secret",
529            32,
530        );
531        assert_eq!(sig16, "650eb9dd6c396a45");
532        assert_eq!(sig16.len(), 16);
533        assert_eq!(sig32.len(), 32);
534        assert!(sig32.starts_with(&sig16)); // longer sig is a prefix extension
535    }
536
537    #[test]
538    fn test_generate_signed_url_with_path_prefix() {
539        let url = generate_signed_url(&SignedUrlBuildRequest {
540            base_url: "https://api.example.com",
541            path_prefix: "attachments",
542            slug: "xK9mP2nQ",
543            agent_id: "test-agent",
544            conversation_id: "conv_123",
545            expires_at: 1_700_000_000_000,
546            secret: "test-secret",
547            sig_length: 32,
548        })
549        .expect("signed URL should build");
550
551        assert!(url.starts_with("https://api.example.com/attachments/xK9mP2nQ?"));
552        assert!(url.contains("sig="));
553    }
554
555    #[test]
556    fn test_derive_signing_key() {
557        let key = derive_signing_key("sk_live_abc123");
558        assert_eq!(
559            key,
560            "fb5f79640e9ed141d4949ccb36110c7aaf829c56d9870942dd77219a57575372"
561        );
562    }
563
564    #[test]
565    fn test_generate_id_format() {
566        let id = generate_id();
567        assert_eq!(id.len(), 8);
568        assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
569    }
570
571    #[test]
572    fn test_generate_id_uniqueness() {
573        let ids: Vec<String> = (0..100).map(|_| generate_id()).collect();
574        let unique: std::collections::HashSet<&String> = ids.iter().collect();
575        assert_eq!(unique.len(), 100, "generated IDs should be unique");
576    }
577
578    #[test]
579    fn test_compute_org_signature() {
580        let sig = compute_org_signature(
581            "xK9mP2nQ",
582            "test-agent",
583            "conv_123",
584            "org_456",
585            1_700_000_000_000.0,
586            "test-secret",
587        );
588        assert_eq!(sig.len(), 32); // default 32 chars for org sigs
589        // Org sig must differ from non-org sig (different payload)
590        let non_org_sig = compute_signature_with_length(
591            "xK9mP2nQ",
592            "test-agent",
593            "conv_123",
594            1_700_000_000_000.0,
595            "test-secret",
596            32,
597        );
598        assert_ne!(sig, non_org_sig);
599    }
600
601    #[test]
602    fn test_compute_org_signature_with_length() {
603        let sig16 = compute_org_signature_with_length(
604            "xK9mP2nQ",
605            "test-agent",
606            "conv_123",
607            "org_456",
608            1_700_000_000_000.0,
609            "test-secret",
610            16,
611        );
612        let sig32 = compute_org_signature_with_length(
613            "xK9mP2nQ",
614            "test-agent",
615            "conv_123",
616            "org_456",
617            1_700_000_000_000.0,
618            "test-secret",
619            32,
620        );
621        assert_eq!(sig16.len(), 16);
622        assert_eq!(sig32.len(), 32);
623        assert!(sig32.starts_with(&sig16));
624    }
625
626    #[test]
627    fn test_is_expired() {
628        assert!(!is_expired(0.0));
629        assert!(is_expired(1.0)); // 1ms after epoch = definitely expired
630        assert!(!is_expired(f64::MAX)); // far future
631    }
632
633    #[test]
634    fn test_verify_signed_url_accepts_32_char_signature_and_path_prefix() {
635        let url = generate_signed_url(&SignedUrlBuildRequest {
636            base_url: "https://api.example.com",
637            path_prefix: "attachments",
638            slug: "xK9mP2nQ",
639            agent_id: "test-agent",
640            conversation_id: "conv_123",
641            expires_at: u64::MAX / 2,
642            secret: "test-secret",
643            sig_length: 32,
644        })
645        .expect("signed URL should build");
646
647        let params = verify_signed_url(&url, "test-secret").expect("signed URL should verify");
648        assert_eq!(params.slug, "xK9mP2nQ");
649        assert_eq!(params.agent_id, "test-agent");
650        assert_eq!(params.conversation_id, "conv_123");
651    }
652
653    #[test]
654    fn test_verify_signed_url_exp_zero_never_expires() {
655        let url = generate_signed_url(&SignedUrlBuildRequest {
656            base_url: "https://api.example.com",
657            path_prefix: "attachments",
658            slug: "xK9mP2nQ",
659            agent_id: "test-agent",
660            conversation_id: "conv_123",
661            expires_at: 0,
662            secret: "test-secret",
663            sig_length: 32,
664        })
665        .expect("signed URL should build");
666
667        let params = verify_signed_url(&url, "test-secret").expect("exp=0 should never expire");
668        assert_eq!(params.slug, "xK9mP2nQ");
669        assert_eq!(params.expires_at, 0);
670    }
671
672    #[test]
673    fn test_compute_diff_identical() {
674        let text = "line 1\nline 2\nline 3";
675        let result = compute_diff(text, text);
676        assert_eq!(result.added_lines(), 0);
677        assert_eq!(result.removed_lines(), 0);
678        assert_eq!(result.added_tokens(), 0);
679        assert_eq!(result.removed_tokens(), 0);
680    }
681
682    #[test]
683    fn test_compute_diff_empty_to_content() {
684        let result = compute_diff("", "line 1\nline 2");
685        assert_eq!(result.added_lines(), 2);
686        assert_eq!(result.removed_lines(), 0);
687    }
688
689    #[test]
690    fn test_compute_diff_content_to_empty() {
691        let result = compute_diff("line 1\nline 2", "");
692        assert_eq!(result.added_lines(), 0);
693        assert_eq!(result.removed_lines(), 2);
694    }
695
696    #[test]
697    fn test_compute_diff_mixed_changes() {
698        let old = "line 1\nline 2\nline 3\nline 4";
699        let new = "line 1\nmodified 2\nline 3\nline 5\nline 6";
700        let result = compute_diff(old, new);
701        // "line 2" and "line 4" removed; "modified 2", "line 5", "line 6" added
702        assert_eq!(result.removed_lines(), 2);
703        assert_eq!(result.added_lines(), 3);
704        assert!(result.added_tokens() > 0);
705        assert!(result.removed_tokens() > 0);
706    }
707
708    #[test]
709    fn test_compute_diff_tokens() {
710        let old = "short";
711        let new = "this is a much longer replacement line";
712        let result = compute_diff(old, new);
713        assert_eq!(result.removed_lines(), 1);
714        assert_eq!(result.added_lines(), 1);
715        assert_eq!(result.removed_tokens(), calculate_tokens("short"));
716        assert_eq!(
717            result.added_tokens(),
718            calculate_tokens("this is a much longer replacement line")
719        );
720    }
721}