svn 0.1.8

Async Rust SVN client for Subversion svn://, svn+ssh://, and ra_svn workflows.
Documentation
use super::*;

#[test]
fn log_merges_requested_revprops_into_map() {
    run_async(async {
        let (mut session, mut server) = connected_session().await;

        let expected_log = SvnItem::List(vec![
            SvnItem::Word("log".to_string()),
            SvnItem::List(vec![
                SvnItem::List(vec![SvnItem::String(b"trunk".to_vec())]),
                SvnItem::List(vec![SvnItem::Number(1)]),
                SvnItem::List(vec![SvnItem::Number(2)]),
                SvnItem::Bool(false),
                SvnItem::Bool(true),
                SvnItem::Number(0),
                SvnItem::Bool(false),
                SvnItem::Word("revprops".to_string()),
                SvnItem::List(vec![
                    SvnItem::String(b"svn:author".to_vec()),
                    SvnItem::String(b"svn:custom".to_vec()),
                ]),
            ]),
        ]);

        let log_entry_item = SvnItem::List(vec![
            SvnItem::List(Vec::new()),
            SvnItem::Number(10),
            SvnItem::List(vec![SvnItem::String(b"alice".to_vec())]),
            SvnItem::List(vec![SvnItem::String(b"2025-01-01".to_vec())]),
            SvnItem::List(vec![SvnItem::String(b"msg".to_vec())]),
            SvnItem::Bool(false),
            SvnItem::Bool(false),
            SvnItem::Number(1),
            SvnItem::List(vec![SvnItem::List(vec![
                SvnItem::String(b"svn:custom".to_vec()),
                SvnItem::String(b"x".to_vec()),
            ])]),
            SvnItem::Bool(false),
        ]);

        let cmd_success = SvnItem::List(vec![
            SvnItem::Word("success".to_string()),
            SvnItem::List(Vec::new()),
        ]);

        let server_task = tokio::spawn(async move {
            assert_eq!(read_line(&mut server).await, encode_line(&expected_log));
            write_item_line(&mut server, &auth_request("realm-1")).await;

            write_item_line(&mut server, &log_entry_item).await;
            write_item_line(&mut server, &SvnItem::Word("done".to_string())).await;
            write_item_line(&mut server, &cmd_success).await;
        });

        let options = LogOptions {
            target_paths: vec!["trunk".to_string()],
            start_rev: Some(1),
            end_rev: Some(2),
            changed_paths: false,
            strict_node: true,
            limit: 0,
            include_merged_revisions: false,
            revprops: LogRevProps::Custom(vec!["svn:author".to_string(), "svn:custom".to_string()]),
        };

        let entries = session.log_with_options(&options).await.unwrap();
        assert_eq!(entries.len(), 1);
        let entry = &entries[0];
        assert_eq!(entry.rev, 10);
        assert_eq!(entry.author.as_deref(), Some("alice"));
        assert_eq!(entry.date.as_deref(), Some("2025-01-01"));
        assert_eq!(entry.message.as_deref(), Some("msg"));
        assert_eq!(entry.rev_props.get("svn:custom").unwrap(), b"x");
        assert_eq!(entry.rev_props.get("svn:author").unwrap(), b"alice");
        assert!(!entry.rev_props.contains_key("svn:date"));
        assert!(!entry.rev_props.contains_key("svn:log"));

        server_task.await.unwrap();
    });
}

#[test]
fn log_each_retrying_reconnects_and_dedups_on_unexpected_eof() {
    run_async(async {
        use std::sync::Arc;
        use std::sync::atomic::{AtomicUsize, Ordering};

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();

        let options = LogOptions {
            target_paths: vec!["trunk".to_string()],
            start_rev: Some(2),
            end_rev: Some(1),
            changed_paths: false,
            strict_node: true,
            limit: 0,
            include_merged_revisions: false,
            revprops: LogRevProps::Custom(vec![
                "svn:author".to_string(),
                "svn:date".to_string(),
                "svn:log".to_string(),
            ]),
        };

        let expected_log = SvnItem::List(vec![
            SvnItem::Word("log".to_string()),
            SvnItem::List(vec![
                SvnItem::List(vec![SvnItem::String(b"trunk".to_vec())]),
                SvnItem::List(vec![SvnItem::Number(2)]),
                SvnItem::List(vec![SvnItem::Number(1)]),
                SvnItem::Bool(false),
                SvnItem::Bool(true),
                SvnItem::Number(0),
                SvnItem::Bool(false),
                SvnItem::Word("revprops".to_string()),
                SvnItem::List(vec![
                    SvnItem::String(b"svn:author".to_vec()),
                    SvnItem::String(b"svn:date".to_vec()),
                    SvnItem::String(b"svn:log".to_vec()),
                ]),
            ]),
        ]);

        let entry_10 = SvnItem::List(vec![
            SvnItem::List(Vec::new()),
            SvnItem::Number(10),
            SvnItem::List(vec![SvnItem::String(b"alice".to_vec())]),
            SvnItem::List(vec![SvnItem::String(b"2025-01-01".to_vec())]),
            SvnItem::List(vec![SvnItem::String(b"msg".to_vec())]),
        ]);
        let entry_9 = SvnItem::List(vec![
            SvnItem::List(Vec::new()),
            SvnItem::Number(9),
            SvnItem::List(vec![SvnItem::String(b"bob".to_vec())]),
            SvnItem::List(vec![SvnItem::String(b"2025-01-02".to_vec())]),
            SvnItem::List(vec![SvnItem::String(b"msg2".to_vec())]),
        ]);

        let cmd_success = SvnItem::List(vec![
            SvnItem::Word("success".to_string()),
            SvnItem::List(Vec::new()),
        ]);

        let accepted = Arc::new(AtomicUsize::new(0));
        let accepted_task = {
            let accepted = accepted.clone();
            tokio::spawn(async move {
                loop {
                    let (mut server, _) = listener.accept().await.unwrap();
                    let attempt = accepted.fetch_add(1, Ordering::SeqCst);

                    handshake_no_auth(&mut server).await;

                    assert_eq!(read_line(&mut server).await, encode_line(&expected_log));
                    write_item_line(&mut server, &auth_request("realm")).await;

                    write_item_line(&mut server, &entry_10).await;

                    if attempt == 0 {
                        // Drop mid-stream.
                        continue;
                    }

                    write_item_line(&mut server, &entry_9).await;
                    write_item_line(&mut server, &SvnItem::Word("done".to_string())).await;
                    write_item_line(&mut server, &cmd_success).await;
                    break;
                }
            })
        };

        let url = SvnUrl::parse(&format!("svn://127.0.0.1:{}/repo", addr.port())).unwrap();
        let client = RaSvnClient::new(url, None, None)
            .with_connect_timeout(Duration::from_secs(1))
            .with_read_timeout(Duration::from_secs(1))
            .with_write_timeout(Duration::from_secs(1))
            .with_reconnect_retries(1);
        let mut session = client.open_session().await.unwrap();

        let mut revs = Vec::new();
        session
            .log_each_retrying(&options, |entry| {
                revs.push(entry.rev);
                Ok(())
            })
            .await
            .unwrap();

        assert_eq!(revs, vec![10, 9]);

        accepted_task.await.unwrap();
        assert_eq!(accepted.load(Ordering::SeqCst), 2);
    });
}