tellur-live 0.2.1

Live-preview server for tellur projects with hot rebuild
Documentation
use std::env;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use tellur_core::raster::Resolution;
use tellur_core::render_context::GpuPreference;
use tellur_live::{serve, AutoBuildOptions, ServerOptions};
use tellur_renderer::ColorRange;

fn main() -> Result<(), Box<dyn Error>> {
    let started_at = Instant::now();
    let args: Vec<String> = env::args().skip(1).collect();
    if args.is_empty() || matches!(args[0].as_str(), "-h" | "--help") {
        println!("{}", usage());
        return Ok(());
    }
    let options = parse_args(args.into_iter())?;
    serve(options.with_started_at(started_at))
}

fn parse_args(mut args: impl Iterator<Item = String>) -> Result<ServerOptions, Box<dyn Error>> {
    let Some(command) = args.next() else {
        return Err(usage().into());
    };
    if command != "serve" {
        return Err(usage().into());
    }

    let mut plugin_path: Option<PathBuf> = None;
    let mut bind: Option<String> = None;
    let mut host = "127.0.0.1".to_owned();
    let mut port = 4317u16;
    let mut resolution = Resolution::new(1280, 720);
    let mut fps = 30u32;
    let mut color_range = ColorRange::Full;
    let mut gpu_preference = GpuPreference::Auto;
    let mut verbose = false;
    let mut auto_build_requested = false;
    let mut build_package: Option<String> = None;
    let mut build_example: Option<String> = None;
    let mut build_manifest: Option<PathBuf> = None;
    let mut watch_paths: Vec<PathBuf> = Vec::new();
    let mut explicit_watch_paths = false;

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--plugin" => {
                plugin_path = Some(PathBuf::from(
                    args.next().ok_or("--plugin requires a path")?,
                ));
            }
            "--bind" => {
                bind = Some(args.next().ok_or("--bind requires an address")?);
            }
            "--host" => {
                host = args.next().ok_or("--host requires an address")?;
            }
            "--port" => {
                port = args
                    .next()
                    .ok_or("--port requires a value")?
                    .parse()
                    .map_err(|_| "--port must be a TCP port")?;
            }
            "--size" => {
                resolution = parse_resolution(&args.next().ok_or("--size requires WxH")?)?;
            }
            "--fps" => {
                fps = args
                    .next()
                    .ok_or("--fps requires a value")?
                    .parse()
                    .map_err(|_| "--fps must be a positive integer")?;
                if fps == 0 {
                    return Err("--fps must be greater than zero".into());
                }
            }
            "--color-range" => {
                color_range =
                    parse_color_range(&args.next().ok_or("--color-range requires a value")?)?;
            }
            "--verbose" => {
                verbose = true;
            }
            "--gpu" => {
                gpu_preference = GpuPreference::PreferGpu;
            }
            "--no-gpu" => {
                gpu_preference = GpuPreference::Disabled;
            }
            "--watch" => {
                auto_build_requested = true;
            }
            "--watch-path" => {
                auto_build_requested = true;
                explicit_watch_paths = true;
                watch_paths.push(PathBuf::from(
                    args.next().ok_or("--watch-path requires a path")?,
                ));
            }
            "-p" | "--package" | "--build-package" => {
                auto_build_requested = true;
                build_package = Some(args.next().ok_or("--build-package requires a name")?);
            }
            "--example" | "--examples" | "--build-example" => {
                auto_build_requested = true;
                build_example = Some(args.next().ok_or("--build-example requires a name")?);
            }
            "--build-manifest" => {
                auto_build_requested = true;
                build_manifest = Some(PathBuf::from(
                    args.next().ok_or("--build-manifest requires a path")?,
                ));
            }
            "-h" | "--help" => return Err(usage().into()),
            other if plugin_path.is_none() => plugin_path = Some(PathBuf::from(other)),
            other => return Err(format!("unknown argument: {other}\n\n{}", usage()).into()),
        }
    }

    let plugin_path = plugin_path
        .or_else(|| build_example.as_deref().map(infer_plugin_path))
        .ok_or_else(usage)?;
    let project_name = build_package
        .clone()
        .or_else(|| infer_example_name(&plugin_path))
        .unwrap_or_else(|| "Project Name".to_owned());
    let auto_build = if auto_build_requested {
        let example = build_example
            .or_else(|| infer_example_name(&plugin_path))
            .ok_or("--watch requires --build-example when it cannot be inferred from --plugin")?;
        if watch_paths.is_empty() && !explicit_watch_paths {
            watch_paths = infer_watch_paths(
                build_package.as_deref(),
                &example,
                build_manifest.as_deref(),
            );
        }
        Some(AutoBuildOptions {
            package: build_package,
            example: Some(example),
            release: true,
            manifest_path: build_manifest,
            watch_paths,
            poll_interval: Duration::from_millis(250),
        })
    } else {
        None
    };

    Ok(ServerOptions {
        plugin_path,
        project_name,
        bind: bind.unwrap_or_else(|| format!("{host}:{port}")),
        resolution,
        fps,
        color_range,
        gpu_preference,
        verbose,
        auto_build,
        started_at: Instant::now(),
    })
}

fn parse_resolution(s: &str) -> Result<Resolution, Box<dyn Error>> {
    let (w, h) = s.split_once('x').ok_or("resolution must be WIDTHxHEIGHT")?;
    Ok(Resolution::new(w.parse()?, h.parse()?))
}

fn parse_color_range(s: &str) -> Result<ColorRange, Box<dyn Error>> {
    s.parse().map_err(|e: String| e.into())
}

fn infer_example_name(path: &Path) -> Option<String> {
    let stem = path.file_stem()?.to_str()?;
    if cfg!(target_os = "windows") {
        Some(stem.to_owned())
    } else {
        Some(stem.strip_prefix("lib").unwrap_or(stem).to_owned())
    }
}

fn infer_plugin_path(example: &str) -> PathBuf {
    target_root()
        .join("release")
        .join("examples")
        .join(dynamic_library_file_name(example))
}

fn dynamic_library_file_name(example: &str) -> String {
    if cfg!(target_os = "windows") {
        format!("{example}.dll")
    } else if cfg!(target_os = "macos") {
        format!("lib{example}.dylib")
    } else {
        format!("lib{example}.so")
    }
}

fn target_root() -> PathBuf {
    if let Some(target_dir) = env::var_os("CARGO_TARGET_DIR") {
        return PathBuf::from(target_dir);
    }
    let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    find_workspace_root(&cwd).unwrap_or(cwd).join("target")
}

fn infer_watch_paths(
    package: Option<&str>,
    example: &str,
    manifest_path: Option<&Path>,
) -> Vec<PathBuf> {
    let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let workspace_root = find_workspace_root(&cwd);
    let mut paths = Vec::new();

    let package_dir = manifest_path
        .and_then(Path::parent)
        .map(Path::to_path_buf)
        .or_else(|| {
            package.and_then(|name| find_workspace_package_dir(name, workspace_root.as_deref()))
        });
    let Some(package_dir) = package_dir else {
        return paths;
    };

    if let Some(root) = &workspace_root {
        push_if_exists(&mut paths, root.join("Cargo.toml"));
        push_if_exists(&mut paths, root.join("Cargo.lock"));
    }

    push_if_exists(&mut paths, package_dir.join("Cargo.toml"));
    push_if_exists(&mut paths, package_dir.join("src"));
    let example_file = package_dir.join("examples").join(format!("{example}.rs"));
    if example_file.exists() {
        paths.push(example_file);
    } else {
        push_if_exists(&mut paths, package_dir.join("examples"));
    }
    collect_path_dependency_watch_paths(&package_dir.join("Cargo.toml"), &mut paths);

    paths
}

fn find_workspace_root(start: &Path) -> Option<PathBuf> {
    for dir in start.ancestors() {
        let manifest = dir.join("Cargo.toml");
        let Ok(contents) = fs::read_to_string(&manifest) else {
            continue;
        };
        if contents.contains("[workspace]") {
            return Some(dir.to_path_buf());
        }
    }
    None
}

fn find_workspace_package_dir(package: &str, workspace_root: Option<&Path>) -> Option<PathBuf> {
    let root = workspace_root?;
    for member in workspace_members(root) {
        let dir = root.join(member);
        if package_name_from_manifest(&dir.join("Cargo.toml")).as_deref() == Some(package) {
            return Some(dir);
        }
    }
    None
}

fn workspace_members(root: &Path) -> Vec<String> {
    let Ok(contents) = fs::read_to_string(root.join("Cargo.toml")) else {
        return Vec::new();
    };
    let Some(start) = contents.find("members") else {
        return Vec::new();
    };
    let Some(open) = contents[start..].find('[').map(|i| start + i) else {
        return Vec::new();
    };
    let Some(close) = contents[open..].find(']').map(|i| open + i) else {
        return Vec::new();
    };
    quoted_values(&contents[open + 1..close])
}

fn package_name_from_manifest(manifest: &Path) -> Option<String> {
    let contents = fs::read_to_string(manifest).ok()?;
    let mut in_package = false;
    for line in contents.lines() {
        let line = line.trim();
        if line.starts_with('[') {
            in_package = line == "[package]";
            continue;
        }
        if in_package && line.starts_with("name") {
            let (_, value) = line.split_once('=')?;
            return first_quoted_value(value);
        }
    }
    None
}

fn collect_path_dependency_watch_paths(manifest: &Path, paths: &mut Vec<PathBuf>) {
    let Some(package_dir) = manifest.parent() else {
        return;
    };
    let Ok(contents) = fs::read_to_string(manifest) else {
        return;
    };
    for dep in path_dependency_values(&contents) {
        let dep_dir = package_dir.join(dep);
        push_if_exists(paths, dep_dir.join("Cargo.toml"));
        push_if_exists(paths, dep_dir.join("src"));
    }
}

fn path_dependency_values(contents: &str) -> Vec<String> {
    contents
        .lines()
        .filter_map(|line| {
            let line = line.trim();
            let (_, rest) = line.split_once("path")?;
            let (_, value) = rest.split_once('=')?;
            first_quoted_value(value)
        })
        .collect()
}

fn quoted_values(value: &str) -> Vec<String> {
    let mut values = Vec::new();
    let mut rest = value;
    while let Some(next) = first_quoted_value(rest) {
        let Some(start) = rest.find('"') else {
            break;
        };
        let tail = &rest[start + 1..];
        let Some(end) = tail.find('"') else {
            break;
        };
        values.push(next);
        rest = &tail[end + 1..];
    }
    values
}

fn first_quoted_value(value: &str) -> Option<String> {
    let start = value.find('"')?;
    let tail = &value[start + 1..];
    let end = tail.find('"')?;
    Some(tail[..end].to_owned())
}

fn push_if_exists(paths: &mut Vec<PathBuf>, path: PathBuf) {
    if path.exists() {
        paths.push(path);
    }
}

fn usage() -> String {
    "usage: tellur-live serve (--plugin <path-to-cdylib> | -p <package> --example <example>) [--host 127.0.0.1] [--port 4317] [--bind 127.0.0.1:4317] [--fps 30] [--color-range full|limited] [--gpu|--no-gpu] [--verbose] [--watch] [--watch-path <path>] [--build-manifest <Cargo.toml>]".to_owned()
}