recentip 0.1.0-alpha.6

An opinionated async SOME/IP implementation for Rust — boring by design, backed by Tokio.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
//! Build script to generate compliance documentation for rustdoc.
//!
//! This reads the requirements and coverage data from spec-data/ and generates
//! a markdown file that is included in rustdoc via `include_str!()`.
//!
//! Automatically runs `scripts/extract_coverage.py` to extract test coverage
//! annotations before generating the documentation.

// Build scripts run at compile time, so panicking primitives are acceptable here.
#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing
)]

use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;

/// Get the current git commit hash, or "main" as fallback.
fn get_git_commit() -> String {
    Command::new("git")
        .args(["rev-parse", "HEAD"])
        .output()
        .ok()
        .and_then(|output| {
            if output.status.success() {
                String::from_utf8(output.stdout)
                    .ok()
                    .map(|s| s.trim().to_string())
            } else {
                None
            }
        })
        .unwrap_or_else(|| "main".to_string())
}

/// Run a Python script if it exists, returning true on success.
fn run_python_script(project_root: &Path, script_name: &str, args: &[&str]) -> bool {
    let script_path = project_root.join(script_name);

    if !script_path.exists() {
        eprintln!("Warning: {script_name} not found, skipping");
        return false;
    }

    let output = Command::new("python3")
        .arg(&script_path)
        .args(args)
        .current_dir(project_root)
        .output();

    match output {
        Ok(result) => {
            if result.status.success() {
                true
            } else {
                eprintln!(
                    "Warning: {} failed: {}",
                    script_name,
                    String::from_utf8_lossy(&result.stderr)
                );
                false
            }
        }
        Err(e) => {
            eprintln!("Warning: Could not run {script_name}: {e}");
            false
        }
    }
}

/// Run the Python script to extract coverage annotations from test files.
/// Outputs to the given directory (should be `OUT_DIR`).
fn run_extract_coverage(project_root: &Path, out_dir: &Path) {
    run_python_script(
        project_root,
        "scripts/extract_coverage.py",
        &[out_dir.to_str().unwrap()],
    );
}

/// Run the Python script to extract requirements from spec RST files.
/// Outputs to the given directory (should be `OUT_DIR`).
/// Returns true if requirements were generated fresh.
fn run_extract_requirements(project_root: &Path, out_dir: &Path) -> bool {
    // Check if specs submodule is available
    let spec_dir = project_root.join("specs/src");
    if !spec_dir.exists() {
        eprintln!("Note: specs submodule not initialized, skipping requirements extraction");
        eprintln!("      Run: git submodule update --init");
        return false;
    }

    eprintln!("Generating requirements.json from spec files...");
    run_python_script(
        project_root,
        "tools/extract_requirements.py",
        &[out_dir.to_str().unwrap()],
    )
}

/// Escape text for rustdoc compatibility.
/// - Escapes pipes and square brackets
/// - Converts angle-bracket URLs `<https://...>` to proper markdown links
/// - Converts parenthesized URLs `(https://...)` to proper markdown links
fn escape_for_rustdoc(text: &str) -> String {
    let mut result = text
        .replace('|', "\\|")
        .replace('[', "\\[")
        .replace(']', "\\]");

    // Convert <URL> to [URL](URL) for proper rustdoc link handling
    let mut start = 0;
    while let Some(open) = result[start..].find("<http") {
        let abs_open = start + open;
        if let Some(close) = result[abs_open..].find('>') {
            let abs_close = abs_open + close;
            let url = &result[abs_open + 1..abs_close];
            let replacement = format!("[{url}]({url})");
            result = format!(
                "{}{}{}",
                &result[..abs_open],
                replacement,
                &result[abs_close + 1..]
            );
            start = abs_open + replacement.len();
        } else {
            break;
        }
    }

    // Convert (URL) to ([URL](URL)) for proper rustdoc link handling
    // Match pattern: "(http" ... ")"
    start = 0;
    while let Some(open) = result[start..].find("(http") {
        let abs_open = start + open;
        if let Some(close) = result[abs_open..].find(')') {
            let abs_close = abs_open + close;
            let url = &result[abs_open + 1..abs_close];
            // Only convert if it looks like a URL (contains ://)
            if url.contains("://") && !url.contains(' ') {
                let replacement = format!("([{url}]({url}))");
                result = format!(
                    "{}{}{}",
                    &result[..abs_open],
                    replacement,
                    &result[abs_close + 1..]
                );
                start = abs_open + replacement.len();
            } else {
                start = abs_close + 1;
            }
        } else {
            break;
        }
    }

    result
}

fn main() {
    // Re-run if test files change (coverage annotations)
    println!("cargo:rerun-if-changed=tests/compliance/");
    // Re-run if spec data changes
    println!("cargo:rerun-if-changed=spec-data/requirements.json");
    // Re-run if git HEAD changes (new commits)
    println!("cargo:rerun-if-changed=.git/HEAD");
    // Re-run if the extraction script changes
    println!("cargo:rerun-if-changed=scripts/extract_coverage.py");

    let project_root = std::env::var("CARGO_MANIFEST_DIR").unwrap();
    let project_root = Path::new(&project_root);

    // Write to OUT_DIR so we don't modify the source directory
    let out_dir = std::env::var("OUT_DIR").unwrap();
    let out_dir = Path::new(&out_dir);
    let output_path = out_dir.join("compliance.md");

    // Extract requirements from spec files (outputs to OUT_DIR)
    let fresh_requirements = run_extract_requirements(project_root, out_dir);

    // Run Python script to extract coverage from test files (outputs to OUT_DIR)
    run_extract_coverage(project_root, out_dir);

    // Use freshly generated requirements if available, otherwise fall back to bundled spec-data
    let requirements_path = if fresh_requirements {
        out_dir.join("requirements.json")
    } else {
        // Fall back to bundled requirements (for published crate without specs submodule)
        project_root.join("spec-data/requirements.json")
    };
    let coverage_path = out_dir.join("coverage.json");

    // Get git commit for stable links
    let git_commit = get_git_commit();

    // Load requirements
    let requirements: Vec<Requirement> = if let Ok(content) = fs::read_to_string(&requirements_path)
    {
        serde_json::from_str(&content).unwrap_or_default()
    } else {
        eprintln!("Warning: Could not read requirements.json, generating stub");
        write_stub(&output_path);
        return;
    };

    // Load coverage (from OUT_DIR where Python script wrote it)
    let coverage: Coverage = fs::read_to_string(&coverage_path).map_or_else(
        |_| Coverage::default(),
        |content| serde_json::from_str(&content).unwrap_or_default(),
    );

    // Generate markdown
    let markdown = generate_compliance_doc(&requirements, &coverage, &git_commit);
    fs::write(&output_path, markdown).expect("Failed to write compliance.md");
}

fn write_stub(path: &Path) {
    let stub = r"# Specification Compliance

> ⚠️ Compliance data not available. Run `python scripts/extract_coverage.py` to generate.
";
    fs::write(path, stub).ok();
}

#[derive(Debug, Default, serde::Deserialize)]
struct Requirement {
    id: String,
    reqtype: String,
    source_file: String,
    text: String,
    section: String,
}

#[derive(Debug, Default, serde::Deserialize)]
struct Coverage {
    tests: Vec<TestCoverage>,
    requirements_to_tests: HashMap<String, Vec<String>>,
}

#[derive(Debug, serde::Deserialize)]
struct TestCoverage {
    test_name: String,
    file_path: String,
    line_number: u32,
}

fn generate_compliance_doc(
    requirements: &[Requirement],
    coverage: &Coverage,
    git_commit: &str,
) -> String {
    let mut out = String::new();

    // Header
    out.push_str("# Specification Compliance\n\n");
    out.push_str(
        "This document provides traceability between the SOME/IP specification requirements\n",
    );
    out.push_str("and the compliance test suite. **Auto-generated at build time.**\n\n");
    out.push_str(&format!(
        "*Git commit: [`{}`](https://github.com/daniel-freiermuth/recentip/commit/{})*\n\n",
        &git_commit[..git_commit.len().min(8)],
        git_commit
    ));

    // Build test lookup: test_name -> TestCoverage
    let test_lookup: HashMap<&str, &TestCoverage> = coverage
        .tests
        .iter()
        .map(|t| (t.test_name.as_str(), t))
        .collect();

    // Build set of requirement IDs by type
    let all_req_ids: std::collections::HashSet<&str> =
        requirements.iter().map(|r| r.id.as_str()).collect();
    let testable_req_ids: std::collections::HashSet<&str> = requirements
        .iter()
        .filter(|r| r.reqtype == "Requirement")
        .map(|r| r.id.as_str())
        .collect();

    // Stats
    let total_reqs = requirements.len();
    let testable_count = testable_req_ids.len();
    let info_count = total_reqs - testable_count;

    // Covered = requirement IDs that exist AND have tests
    let covered_testable = coverage
        .requirements_to_tests
        .keys()
        .filter(|id| testable_req_ids.contains(id.as_str()))
        .count();
    // Info items that also have tests
    let covered_info = coverage
        .requirements_to_tests
        .keys()
        .filter(|id| all_req_ids.contains(id.as_str()) && !testable_req_ids.contains(id.as_str()))
        .count();
    let covered_all = covered_testable + covered_info;

    // Coverage % only based on testable requirements
    let coverage_pct = if testable_count > 0 {
        (covered_testable as f64 / testable_count as f64) * 100.0
    } else {
        0.0
    };

    out.push_str("## Summary\n\n");
    out.push_str("| Metric | Count |\n");
    out.push_str("|--------|-------|\n");
    out.push_str(&format!("| Total Requirements | {total_reqs} |\n"));
    out.push_str(&format!("| Requirements (testable) | {testable_count} |\n"));
    out.push_str(&format!("| Information (non-testable) | {info_count} |\n"));
    out.push_str(&format!("| Covered (testable) | {covered_testable} |\n"));
    out.push_str(&format!("| Covered (info) | {covered_info} |\n"));
    out.push_str(&format!("| **Total Covered** | **{covered_all}** |\n"));
    out.push_str(&format!(
        "| Not Yet Covered | {} |\n",
        testable_count.saturating_sub(covered_testable)
    ));
    out.push_str(&format!("| **Coverage** | **{coverage_pct:.1}%** |\n\n"));

    // Group requirements by source file
    let mut by_source: HashMap<&str, Vec<&Requirement>> = HashMap::new();
    for req in requirements {
        by_source.entry(&req.source_file).or_default().push(req);
    }

    // Coverage by document (before the huge table)
    out.push_str("## Coverage by Document\n\n");
    out.push_str("| Document | Requirements | Covered | Coverage |\n");
    out.push_str("|----------|-------------|---------|----------|\n");

    for (source, reqs) in &by_source {
        let testable: Vec<_> = reqs.iter().filter(|r| r.reqtype == "Requirement").collect();
        let covered_count = testable
            .iter()
            .filter(|r| coverage.requirements_to_tests.contains_key(&r.id))
            .count();
        let pct = if testable.is_empty() {
            0.0
        } else {
            (covered_count as f64 / testable.len() as f64) * 100.0
        };
        // Create anchor ID from source file name (remove .rst extension)
        let doc_anchor = source.replace(".rst", "").replace('.', "-");
        out.push_str(&format!(
            "| [{}](#doc-{}) | {} | {} | {:.0}% |\n",
            source,
            doc_anchor,
            testable.len(),
            covered_count,
            pct
        ));
    }
    out.push('\n');

    // Full requirements overview table
    out.push_str("## All Requirements\n\n");
    out.push_str("| Status | ID | Summary | Type | Tests | Details |\n");
    out.push_str("|:------:|----|---------| -----|:-----:|--------|\n");

    // Collect all requirements sorted by ID
    let mut all_reqs: Vec<&Requirement> = requirements.iter().collect();
    all_reqs.sort_by(|a, b| a.id.cmp(&b.id));

    for req in &all_reqs {
        let test_count = coverage
            .requirements_to_tests
            .get(&req.id)
            .map_or(0, std::vec::Vec::len);

        let is_info = req.reqtype == "Information";
        let is_covered = test_count > 0;

        // Green if info or covered, red otherwise
        let status = if is_info || is_covered { "" } else { "" };

        // Truncate summary to ~60 chars
        let summary = if req.text.len() > 60 {
            format!("{}...", &req.text.chars().take(60).collect::<String>())
        } else {
            req.text.clone()
        };
        // Escape pipes and clean up for table
        let safe_summary = summary.replace('|', "\\|").replace('\n', " ");

        let req_type = if is_info { "Info" } else { "Req" };

        let test_display = if test_count > 0 {
            format!("{test_count}")
        } else {
            "".to_string()
        };

        // Link to detailed section
        let details_link = format!("[→](#{})", req.id);

        out.push_str(&format!(
            "| {} | {} | {} | {} | {} | {} |\n",
            status, req.id, safe_summary, req_type, test_display, details_link
        ));
    }
    out.push('\n');

    // Covered requirements with linked tests
    out.push_str("## Covered Requirements\n\n");
    out.push_str("Each requirement below includes the full specification text and links to verifying tests.\n\n");

    for (source, reqs) in &by_source {
        // Include any requirement (Requirement or Information) that has tests
        let covered_reqs: Vec<_> = reqs
            .iter()
            .filter(|r| coverage.requirements_to_tests.contains_key(&r.id))
            .collect();

        if covered_reqs.is_empty() {
            continue;
        }

        let doc_anchor = source.replace(".rst", "").replace('.', "-");
        out.push_str(&format!("<a id=\"doc-{doc_anchor}\"></a>\n\n"));
        out.push_str(&format!("### {source}\n\n"));

        // Group requirements by section
        let mut by_section: HashMap<&str, Vec<&&Requirement>> = HashMap::new();
        for req in &covered_reqs {
            by_section.entry(&req.section).or_default().push(req);
        }

        // Sort sections alphabetically
        let mut sections: Vec<_> = by_section.keys().collect();
        sections.sort();

        for section in sections {
            let section_reqs = &by_section[section];
            out.push_str(&format!("#### {section}\n\n"));

            for req in section_reqs {
                let type_badge = if req.reqtype == "Information" {
                    " *(Info)*"
                } else {
                    ""
                };
                // Escape pipes and square brackets for markdown/rustdoc compatibility
                // Also convert angle-bracket URLs to proper markdown links
                let safe_text = escape_for_rustdoc(&req.text);
                out.push_str(&format!("<a id=\"{}\"></a>\n\n", req.id));
                out.push_str(&format!("##### {}{}\n\n", req.id, type_badge));
                out.push_str(&format!("> {safe_text}\n\n"));
                out.push_str("**Tests:**\n\n");

                if let Some(test_names) = coverage.requirements_to_tests.get(&req.id) {
                    for test_name in test_names {
                        if let Some(test) = test_lookup.get(test_name.as_str()) {
                            // Link to GitHub repository at specific commit
                            out.push_str(&format!(
                                "- [`{}`](https://github.com/daniel-freiermuth/recentip/blob/{}/{}#L{})\n",
                                test_name, git_commit, test.file_path, test.line_number
                            ));
                        } else {
                            out.push_str(&format!("- `{test_name}`\n"));
                        }
                    }
                }
                out.push('\n');
            }
        }
    }

    // Not covered requirements
    out.push_str("## Not Yet Covered\n\n");
    out.push_str("Requirements without test coverage. Contributions welcome!\n\n");

    for (source, reqs) in &by_source {
        let uncovered: Vec<_> = reqs
            .iter()
            .filter(|r| {
                r.reqtype == "Requirement" && !coverage.requirements_to_tests.contains_key(&r.id)
            })
            .collect();

        if uncovered.is_empty() {
            continue;
        }

        out.push_str(&format!(
            "### {} ({} uncovered)\n\n",
            source,
            uncovered.len()
        ));
        out.push_str("<details>\n<summary>Click to expand</summary>\n\n");

        // Group by section
        let mut by_section: HashMap<&str, Vec<&&Requirement>> = HashMap::new();
        for req in &uncovered {
            by_section.entry(&req.section).or_default().push(req);
        }

        let mut sections: Vec<_> = by_section.keys().collect();
        sections.sort();

        for section in sections {
            let section_reqs = &by_section[section];
            out.push_str(&format!("#### {section}\n\n"));

            for req in section_reqs {
                // Escape for markdown/rustdoc compatibility
                let safe_text = escape_for_rustdoc(&req.text);
                out.push_str(&format!("<a id=\"{}\"></a>\n\n", req.id));
                out.push_str(&format!("##### {}\n\n", req.id));
                out.push_str(&format!("> {safe_text}\n\n"));
            }
        }

        out.push_str("\n</details>\n\n");
    }

    out
}