verifyos-cli 0.13.1

AI agent-friendly Rust CLI for scanning iOS app bundles for App Store rejection risks before submission.
Documentation
use miette::Diagnostic;
use std::path::{Path, PathBuf};
use thiserror::Error;

#[derive(Debug, Error, Diagnostic)]
pub enum WorkspaceError {
    #[error("Failed to read Xcode workspace at {path}")]
    ReadError { path: String, description: String },
    #[error("Missing contents.xcworkspacedata in workspace {path}")]
    MissingContents { path: String },
}

#[derive(Debug, Clone)]
pub struct Xcworkspace {
    pub project_paths: Vec<PathBuf>,
}

impl Xcworkspace {
    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
        let path = path.as_ref();
        let contents_path = if path
            .extension()
            .is_some_and(|ext| ext.eq_ignore_ascii_case("xcworkspacedata"))
        {
            path.to_path_buf()
        } else {
            let workspace_root = path;
            let contents = workspace_root.join("contents.xcworkspacedata");
            if !contents.exists() {
                return Err(WorkspaceError::MissingContents {
                    path: workspace_root.display().to_string(),
                });
            }
            contents
        };

        let data =
            std::fs::read_to_string(&contents_path).map_err(|e| WorkspaceError::ReadError {
                path: contents_path.display().to_string(),
                description: format!("{e}"),
            })?;

        let workspace_dir = contents_path
            .parent()
            .map(Path::to_path_buf)
            .unwrap_or_else(|| PathBuf::from("."));

        let mut project_paths = Vec::new();
        for location in extract_locations(&data) {
            if let Some(path) = resolve_location(&workspace_dir, &location) {
                if path.extension().is_some_and(|ext| ext == "xcodeproj") {
                    project_paths.push(path);
                }
            }
        }

        Ok(Self { project_paths })
    }
}

fn extract_locations(data: &str) -> Vec<String> {
    let mut locations = Vec::new();
    let needle = "location=\"";
    let mut start = 0;
    while let Some(pos) = data[start..].find(needle) {
        let idx = start + pos + needle.len();
        if let Some(end) = data[idx..].find('"') {
            locations.push(data[idx..idx + end].to_string());
            start = idx + end + 1;
        } else {
            break;
        }
    }
    locations
}

fn resolve_location(workspace_dir: &Path, location: &str) -> Option<PathBuf> {
    if let Some(rest) = location.strip_prefix("group:") {
        Some(workspace_dir.join(rest))
    } else if let Some(rest) = location.strip_prefix("container:") {
        Some(workspace_dir.join(rest))
    } else if let Some(rest) = location.strip_prefix("absolute:") {
        Some(PathBuf::from(rest))
    } else if location.starts_with('/') {
        Some(PathBuf::from(location))
    } else {
        Some(workspace_dir.join(location))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn parses_workspace_file_refs() {
        let dir = tempdir().expect("tempdir");
        let workspace_dir = dir.path().join("Demo.xcworkspace");
        fs::create_dir_all(&workspace_dir).expect("workspace dir");
        let contents = workspace_dir.join("contents.xcworkspacedata");
        fs::write(
            &contents,
            r#"<?xml version="1.0" encoding="UTF-8"?>
<Workspace version="1.0">
   <FileRef location="group:Demo.xcodeproj"></FileRef>
</Workspace>"#,
        )
        .expect("write contents");

        let workspace = Xcworkspace::from_path(&workspace_dir).expect("parse workspace");
        assert_eq!(workspace.project_paths.len(), 1);
        assert!(workspace.project_paths[0].ends_with("Demo.xcodeproj"));
    }
}