Reject Invalid Shifts Without Losing Captions

RustBeginner
Practice Now

Introduction

A correct subtitle transformation must protect the whole file, including captions after the first good example. A malformed late cue must not leave a partial result, and an existing output must not be overwritten just because the new run appears successful.

You will implement the Rust file boundary around the complete cue transformation, then exercise Unicode, multiline text, legitimate overlap, invalid shifts, missing paths and output conflicts. This independent environment supplies the previous lab's complete millisecond and shift functions. No files from its VM are needed. The prepared player reads your actual selected output; it does not rebuild captions from the original source.

You should understand the previous lab's format and time rules, along with its Rust prerequisites. This lab adds the create-new output boundary; it does not teach video encoding or claim support for all WebVTT syntax. The author estimates 50 minutes including reading and ordinary recovery, not a measured beginner completion time.

Validate Everything Before Creating Output

In this step, you will implement the complete read, validation and new-file creation sequence. Open Terminal. Add Cargo to this shell's path, choose the available UTF-8 locale for the session, and enter the independent project:

export PATH=/home/labex/.cargo/bin:$PATH
export LANG=C.UTF-8 LC_ALL=C.UTF-8
cd /home/labex/project/subtitle-sync

Repeat the exports in any new terminal and the directory command if you lose your location. They do not edit startup files. Terminal Unicode glyphs may still appear as placeholders; the actual UTF-8 file bytes and browser captions preserve their text.

Edit only src/export.rs, retaining transform(input: &Path, output: &Path, delta: i64) -> Result<usize, String>. Keep every other source file, Cargo.toml, rust-toolchain.toml and original file under data/ unchanged. Complete time functions are already present in src/time.rs; their public preconditions remain parser-validated nonnegative time fields and valid Cue objects. You may implement an equivalent internal sequence, but all validation must finish before creating output. Unchanged prepared files retain bytes, permissions, owner/group and modification time; access time from ordinary reads is excluded.

The previous lab's controlled plain-text WebVTT subset applies here. It requires an exact WEBVTT header and an empty next line. Timing lines use HH:MM:SS.mmm --> HH:MM:SS.mmm with the exact spaces shown, ASCII digits, at least two hour digits, two minute/second digits in 00–59 and three millisecond digits. Complete times must fit nonnegative i64 milliseconds. Starts are nondecreasing; equal starts and overlap are legal; ends exceed starts. A cue has one or more nonempty logical payload lines. Unicode, tabs, multiline text and leading/trailing spaces survive, including wholly whitespace payloads. A truly empty line ends the cue; orphan text after it is rejected. LF/CRLF, an optional final newline and extra empty separators are allowed on input. The prepared output uses LF, two hour digits for 0–99 hours (only the necessary additional digits for larger hours), one empty line after the header and one after each cue, including the last. It normalizes timestamp spelling and separator counts while preserving logical payload text. Bare carriage returns, BOM/header metadata, cue IDs/settings, NOTE/STYLE/REGION, markup/entities and payload <, > or &, and controls other than tabs are unsupported by this course, rather than silently discarded.

Inspect the same real source, then deliberately try the unfinished export:

cargo run --quiet -- inspect data/original.vtt
cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000

The second command should return nonzero with transform is unfinished; it must not create a file. The original caption starts at 4000–7000 ms and the edited film's green scene starts at 2000 ms. The required shift is still -2000 for every endpoint of every cue.

Before reading the explanation, write your own safe order for these deliberately unordered actions: create a new target, render text, parse all cues, write bytes, read the named input, shift every cue. Read the late-invalid fixture and predict whether a target should exist if its second cue fails:

cat data/bad-late.vtt

Now compare your ordering with the side-effect boundary. The file boundary should follow this order. First read the actual named input as UTF-8 text. Parse all cues and reject unsupported or malformed content with cue context. Then shift the whole collection using checked arithmetic, rejecting negative or overflowing times. Render the complete result in memory. Only after all those operations succeed, create a new output and write those prepared bytes.

Creating output before complete parsing, shifting and rendering could leave an empty or partial file after a late failure. Writing with replacement semantics could destroy a useful earlier result. OpenOptions configures how a file is opened. .write(true).create_new(true) requests a writable, newly created file; if any object already occupies the path, including a dangling symbolic link, opening fails without truncating it. This atomic create-new decision avoids a separate exists-then-create race. It is not a general storage transaction guarantee for an unrelated I/O failure after creation.

Path is a borrowed filesystem path. fs::read_to_string returns the complete UTF-8 String or an I/O error. map_err adds the actual failing path while keeping its message; ? returns that error immediately. The supplied parse, shift_all and render work on complete in-memory data. Write is the standard trait that supplies write_all, which attempts to write every byte in the prepared buffer. Importing it makes that method available; no trait implementation is required. as_bytes borrows the String's existing UTF-8 bytes without taking character-sized slices. shifted.len() is the number of cues, returned only after writing succeeds.

Read the actual starter, including its imports:

cat src/export.rs

It currently imports only Path. Do not assume the other names are available. Add use std::fs::{self, OpenOptions}; for the filesystem module and builder, use std::io::Write; for the trait that makes write_all available, and use crate::{parse, render, shift_all}; for the prepared library functions. Keep use std::path::Path;. One clear import order is fs/OpenOptions, Write, Path, then the crate functions, as in the optional reference; order itself is not graded. The braces in a use group import several names; they are not a function body.

Before editing, make a checkpoint of only the allowed source. mktemp -d creates a unique directory by replacing the six X characters; it refuses an existing name. $() captures that path into a variable for this shell session. Quotes keep the path together, and && copies only after directory creation succeeds. cp -p copies the file while retaining its permissions and modification time. This is a source checkpoint, not a backup of any output caption. Print the variable and keep the actual name; do not guess the suffix.

rs01_before_export=$(mktemp -d .edit-before-export.XXXXXX) && cp -p src/export.rs "$rs01_before_export/export.rs"
printf '%s\n' "$rs01_before_export"

printf prints this directory name, followed by a newline. Open the existing source with nano; -c displays the cursor position:

nano -c src/export.rs

Search with Ctrl+W for pub fn transform, then Enter. Keep its types and return signature. Rename _input, _output and _delta by removing their leading underscores. The body is between this function's opening brace and the last closing brace; change that body, not the supplied time module.

Implement your safe sequence as a complete function before running it. Read the actual input into raw, parse it into cues, shift all cues into shifted, and render complete. For each fallible input operation, add the real input path with .map_err(|error| format!("{}: {error}", input.display()))?. Rendering itself returns a String, not Result. Then configure a mutable output file through OpenOptions::new().write(true).create_new(true).open(output), adding an output-path error on failure. Finally file.write_all(complete.as_bytes()) must succeed before Ok(shifted.len()). Explain to yourself which first expression can create a target and why every cue is already checked at that point. Equivalent implementations with the same public behavior are allowed.

Use Ctrl+O, Enter, Ctrl+X to save and leave nano. Read the saved source:

cat src/export.rs

Run the real source through this boundary:

cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000
Created output/aligned.vtt with 3 cues (offset -2000 ms)
cargo run --quiet -- inspect output/aligned.vtt
Cue 1: 2000 --> 5000 ms
Café 林地 🌿
Second line intact
Cue 2: 4000 --> 7000 ms
Overlap stays
Cue 3: 59750 --> 62250 ms
Across a minute

All three cues are present, including both text lines and the overlapping intervals. If the output already exists after an earlier attempt, keep it: the next step gives a non-overwriting recovery path before recreating this exact filename.

If you have not saved an accidental edit, press Ctrl+X, then N at the save question to leave the file unchanged. For a saved mistake, read Cargo's named file and line, reopen this source and correct that local expression. Alternatively restore your own checkpoint. The export checkpoint contains the state before this phase; restoring it means you must redo this phase and any later functions.

If you opened a new Terminal, its variables and working directory do not carry over. Repeat the visible environment and directory commands first:

export PATH=/home/labex/.cargo/bin:$PATH
export LANG=C.UTF-8 LC_ALL=C.UTF-8
cd /home/labex/project/subtitle-sync

The following recovery commands are conditional: use them only when undoing a saved mistake. ls -d lists directory names themselves; * matches their generated suffixes. If no checkpoint exists, read that error and repair the source instead of inventing a path.

ls -d .edit-before-export.*

read waits for one line and stores it in a session variable. Run it, then type or paste one actual complete directory name printed above and press Enter. Do not type an example suffix or the dollar sign:

read rs01_restore

Confirm that the file is the expected earlier source before restoring it:

cat "$rs01_restore/export.rs"
cp -p "$rs01_restore/export.rs" src/export.rs

Then reopen nano, redo the missing implementation and rerun the failed command. Do not rerun setup or change protected input files. A code fix does not replace an existing output VTT or the file already loaded into a browser Blob; preserve the old result before regenerating its required path, then select that actual output and click Load actual files again.

Optional complete export.rs comparison and recovery reference

Try the function edits and inspect their results before opening this reference. It shows one complete implementation, not a required command sequence. Equivalent algorithms are allowed under the public contract. Compare the affected function rather than replacing already completed work unnecessarily.

use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
use crate::{parse, render, shift_all};

pub fn transform(input: &Path, output: &Path, delta: i64) -> Result<usize, String> {
    let raw = fs::read_to_string(input)
        .map_err(|error| format!("{}: {error}", input.display()))?;
    let cues = parse(&raw)
        .map_err(|error| format!("{}: {error}", input.display()))?;
    let shifted = shift_all(&cues, delta)
        .map_err(|error| format!("{}: {error}", input.display()))?;
    let complete = render(&shifted);
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(output)
        .map_err(|error| format!("{}: cannot create new output: {error}", output.display()))?;
    file.write_all(complete.as_bytes())
        .map_err(|error| format!("{}: write failed: {error}", output.display()))?;
    Ok(shifted.len())
}

Recover from Errors While Keeping the Evidence

In this step, you will distinguish file conflicts, malformed cues and invalid shifts, then confirm real video playback. Continue in Terminal, in the project directory. The failures below are intentional and should return nonzero; they must not print a success message or create the requested new output.

First repeat the successful command against its existing result:

cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000

Expect an error naming the existing output/aligned.vtt. It stays unchanged. To preserve an earlier result before rebuilding the required path, create a fresh backup directory. mktemp -d replaces the six X characters with a unique suffix and refuses to reuse an existing directory. $() captures that path into a session variable, and quotes keep it together. && runs mv only when the directory was created successfully; mv moves the old result into that new empty directory, so another backup is not overwritten.

rs01_backup_dir=$(mktemp -d output/aligned-backup.XXXXXX) &&
mv output/aligned.vtt "$rs01_backup_dir/aligned.vtt"

If directory creation fails, the move is skipped and the old result remains. Read that error before retrying. Regenerate the exact required output with your current implementation:

cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000

Now read the fixture with a malformed late cue:

cat data/bad-late.vtt

Its first cue is good, but cue 2 ends before it starts. A partial prefix is not a usable export. This next command should return nonzero and identify the actual source, cue 2 and its timing line:

cargo run --quiet -- shift data/bad-late.vtt output/bad-result.vtt 0

Use ls, which lists a named path, to check that no output appeared. This command is also expected to return nonzero because the result does not exist:

ls output/bad-result.vtt

Recover by choosing the valid source and a separate new output:

cargo run --quiet -- shift data/original.vtt output/after-bad-input.vtt -2000

A wrong path is a different error. Use an intentional plural typo:

cargo run --quiet -- shift data/originals.vtt output/after-path-error.vtt -2000

It should name the missing input path, without inventing a cue error. Correct the path:

cargo run --quiet -- shift data/original.vtt output/after-path-error.vtt -2000

A -4001 offset would move the first 4000 ms start below zero. The next command must reject this shift rather than wrap it to a huge positive number or clip it:

cargo run --quiet -- shift data/original.vtt output/negative-result.vtt -4001

An enormous but valid i64 offset can overflow when added to an endpoint. This failure must also return an attributed error without creating output:

cargo run --quiet -- shift data/original.vtt output/overflow-result.vtt 9223372036854775807

A number outside i64 is rejected even earlier by argument parsing. This is still a recoverable error, not a panic:

cargo run --quiet -- shift data/original.vtt output/invalid-offset.vtt 9223372036854775808

Recover with the known valid offset and another new filename:

cargo run --quiet -- shift data/original.vtt output/after-offset-errors.vtt -2000

All of those failures left the original captions and film available. The i64 extreme cases verify arithmetic, not millisecond-precise browser playback at enormous times; browser observations here concern this short film.

Inspect another valid source with CRLF line endings, equal-start overlapping cues, a whitespace-only payload and Unicode text:

cargo run --quiet -- inspect data/whitespace.vtt

It contains a payload line made only of spaces and a tab. That line is nonempty and therefore belongs to its cue. A truly empty line instead separates cues; an empty payload or an orphan text continuation is unsupported. The prepared reader retains this distinction and never silently filters a cue. Export the valid case across a minute boundary:

cargo run --quiet -- shift data/whitespace.vtt output/whitespace-shifted.vtt 58001

Switch to Preview. Keep Video as data/edited.webm, select Caption file output/aligned.vtt, and click Load actual files. At Time (seconds) 2.5, click Go to time and look for the green scene and complete two-line Unicode caption. At 4.5, both overlapping cues should be visible; at 60.5, the amber scene should show the minute-boundary cue. Click Play to confirm actual playback, then pause. The loaded filename identifies your actual result. The download buttons save the loaded files without rebuilding them.

You can also select output/whitespace-shifted.vtt and inspect 60.5. Both equal-start cues are loaded; the wholly whitespace caption can be visually blank. The saved file and loaded cue text retain the other cue's leading/trailing spaces and second line, although browser layout need not display each space or tab as a distinguishable width. Exact logical text is checked from the file, rather than inferred from its visual spacing. Browser active-cue order can differ for equal starts; the output file's cue order is still preserved.

Return to Terminal and inspect the main accepted result one final time:

cargo run --quiet -- inspect output/aligned.vtt

If a command has a spelling or path error, correct the command instead of editing protected data. If your Rust code is wrong, use the source-only checkpoint confirmation or optional comparison reference from step 1 to repair the affected function, then rerun. When recreating an existing output after a code fix, preserve it with the unique-directory backup operation first. You now have a complete result plus earlier evidence, rather than a successful-looking partial file.

Summary

You implemented the file side-effect boundary after complete cue validation and transformation. Existing output paths were refused without replacement, late bad data produced no partial export, and ordinary path, negative-time and overflow errors remained recoverable. You preserved earlier work in unique backup directories and verified actual Unicode, multiline and overlapping captions with the real player. The challenge will combine these taught boundaries for another short film with a different public offset.