> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formae.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 06 - Read

> Implement the synchronous Read method and the NotFound semantics that let formae detect out-of-band deletions.

This section implements the `Read` method. Read is a **synchronous operation** that retrieves the current state of a resource.

## What Read does

The agent calls `Read` to get the current state of a resource from the infrastructure. This happens:

* When a user runs `formae status` or `formae inventory`
* During synchronization (the agent periodically reads all managed resources)
* Before an update (to detect out-of-band changes)

```
┌─────────┐         ┌────────┐         ┌──────────────┐
│  Agent  │         │ Plugin │         │ Infrastructure│
└────┬────┘         └───┬────┘         └──────┬───────┘
     │                  │                     │
     │  Read(nativeID)  │                     │
     │─────────────────>│  Get file           │
     │                  │────────────────────>│
     │  Properties      │  File contents      │
     │<─────────────────│<────────────────────│
```

## ReadRequest

The agent sends a `ReadRequest` to retrieve the current state of a resource:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
type ReadRequest struct {
    NativeID     string          // Infrastructure identifier (returned from Create)
    ResourceType string          // e.g., "SFTP::Files::File"
    TargetConfig json.RawMessage // Connection details for the target
}
```

## ReadResult

Unlike mutating operations that return `ProgressResult`, Read returns a simpler `ReadResult` directly:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
type ReadResult struct {
    ResourceType string             // Echo back the resource type
    Properties   string             // JSON string of current properties
    ErrorCode    OperationErrorCode // Set if resource cannot be read
}
```

Read is synchronous: it returns the current state immediately. If the resource doesn't exist, set `ErrorCode` to `NotFound` (don't return a Go error). See [10 - Error handling](/plugin-development/tutorial/10-error-handling) for more on error codes.

## Test

Add a test to `sftp_test.go`:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
// =============================================================================
// Read Tests
// =============================================================================

// TestRead verifies that the Plugin.Read method:
// 1. Returns the current state of an existing file
// 2. Properties match what was written to the server
//
// Setup: Create file using asyncsftp
// Execute: Read using Plugin
// Verify: Assert on the returned properties
func TestRead(t *testing.T) {
	if os.Getenv("SFTP_USERNAME") == "" || os.Getenv("SFTP_PASSWORD") == "" {
		t.Skip("SFTP_USERNAME and SFTP_PASSWORD must be set")
	}

	ctx := context.Background()

	// --- Setup: Create file using asyncsftp ---
	client, err := asyncsftp.NewClient(testConfig())
	require.NoError(t, err, "failed to create asyncsftp client")
	defer client.Close()

	filePath := "/upload/test-read.txt"
	fileContent := "Hello from Read test"
	filePermissions := os.FileMode(0644)

	opID := client.StartUpload(filePath, fileContent, filePermissions)

	// Wait for upload to complete
	require.Eventually(t, func() bool {
		op, err := client.GetStatus(opID)
		if err != nil {
			return false
		}
		return op.State == asyncsftp.StateCompleted
	}, 10*time.Second, 100*time.Millisecond, "file upload should complete")

	// --- Execute: Read using Plugin ---
	plugin := &Plugin{}

	req := &resource.ReadRequest{
		NativeID:     filePath,
		ResourceType: "SFTP::Files::File",
		TargetConfig: testTargetConfig(),
	}

	result, err := plugin.Read(ctx, req)
	require.NoError(t, err, "Read should not return error")

	// ErrorCode should be empty for existing file
	assert.Empty(t, result.ErrorCode, "Read should not return error code for existing file")

	// --- Verify: Assert on returned properties ---
	require.NotEmpty(t, result.Properties, "Read should return properties")

	var props map[string]any
	err = json.Unmarshal([]byte(result.Properties), &props)
	require.NoError(t, err, "Properties should be valid JSON")

	assert.Equal(t, filePath, props["path"], "path should match")
	assert.Equal(t, fileContent, props["content"], "content should match")
	assert.Equal(t, "0644", props["permissions"], "permissions should match")
	assert.NotEmpty(t, props["size"], "size should be set")
	assert.NotEmpty(t, props["modifiedAt"], "modifiedAt should be set")

	t.Logf("Read returned properties: path=%s, size=%v", props["path"], props["size"])

	// --- Cleanup ---
	_ = client.StartDelete(filePath)
}
```

### Test pattern

| Step    | Action                  | Tool      |
| ------- | ----------------------- | --------- |
| Setup   | Create file             | asyncsftp |
| Execute | Read file               | Plugin    |
| Verify  | Assert properties match | assert    |

## Implementation

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
// Read retrieves the current state of a resource.
func (p *Plugin) Read(ctx context.Context, req *resource.ReadRequest) (*resource.ReadResult, error) {
	// Get SFTP client
	client, err := p.getClient(req.TargetConfig)
	if err != nil {
		return &resource.ReadResult{
			ResourceType: req.ResourceType,
			ErrorCode:    resource.OperationErrorCodeInternalFailure,
		}, nil
	}

	// Read file from SFTP server
	fileInfo, err := client.ReadFile(req.NativeID)
	if err != nil {
		return &resource.ReadResult{
			ResourceType: req.ResourceType,
			ErrorCode:    resource.OperationErrorCodeInternalFailure,
		}, nil
	}

	// Convert to JSON properties
	props := FileProperties{
		Path:        fileInfo.Path,
		Content:     fileInfo.Content,
		Permissions: fileInfo.Permissions,
		Size:        fileInfo.Size,
		ModifiedAt:  fileInfo.ModifiedAt.Format("2006-01-02T15:04:05Z07:00"),
	}
	propsJSON, _ := json.Marshal(props)

	return &resource.ReadResult{
		ResourceType: req.ResourceType,
		Properties:   string(propsJSON),
	}, nil
}
```

## Verify

Run the test to confirm Read retrieves file properties correctly:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
SFTP_USERNAME=testuser SFTP_PASSWORD=testpass go test -tags=integration -run "TestRead$" -v
```

```text theme={"languages":{"custom":["/languages/pkl.json"]}}
=== RUN   TestRead
    sftp_test.go:207: Read returned properties: path=/upload/test-read.txt, size=20
--- PASS: TestRead (0.52s)
PASS
```

***

## Handling NotFound

Now we need to handle a critical edge case: what happens when the resource doesn't exist?

### Why NotFound matters

The agent periodically calls Read on all managed resources to synchronize formae's state with the actual infrastructure. This is how formae detects **out-of-band changes**, modifications made outside of formae (e.g., someone manually deleted a file via SSH).

When a resource no longer exists:

* The plugin must **return a result with `ErrorCode = NotFound`**
* The plugin must **not return an error**
* The agent then marks the resource as deleted in its inventory

```
┌─────────┐         ┌────────┐         ┌──────────────┐
│  Agent  │         │ Plugin │         │ Infrastructure│
└────┬────┘         └───┬────┘         └──────┬───────┘
     │                  │                     │
     │  [file deleted out-of-band]            │
     │                  │                     │
     │  Read(fileA)     │                     │
     │─────────────────>│  Get file           │
     │                  │────────────────────>│
     │  NotFound        │  File not found     │
     │<─────────────────│<────────────────────│
     │                  │                     │
     │  [agent marks fileA as deleted]        │
```

### NotFound test

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
// TestReadNotFound verifies that Plugin.Read returns OperationErrorCodeNotFound
// when the file does not exist.
//
// This is critical for formae's synchronization mechanism. The agent periodically
// calls Read on all managed resources to sync formae's state with the actual
// infrastructure state. When a resource is deleted out-of-band (outside of formae),
// the plugin must return NotFound (not an error). The agent then marks the resource
// as deleted in its inventory.
//
// Important: Plugins must return a result with ErrorCode=NotFound, NOT an error.
func TestReadNotFound(t *testing.T) {
	if os.Getenv("SFTP_USERNAME") == "" || os.Getenv("SFTP_PASSWORD") == "" {
		t.Skip("SFTP_USERNAME and SFTP_PASSWORD must be set")
	}

	ctx := context.Background()
	plugin := &Plugin{}

	// Read a file that does not exist
	req := &resource.ReadRequest{
		NativeID:     "/upload/this-file-does-not-exist.txt",
		ResourceType: "SFTP::Files::File",
		TargetConfig: testTargetConfig(),
	}

	result, err := plugin.Read(ctx, req)

	// IMPORTANT: Read should NOT return an error for NotFound
	require.NoError(t, err, "Read should not return error for missing file")

	// Should return NotFound error code
	assert.Equal(t, resource.OperationErrorCodeNotFound, result.ErrorCode,
		"Read should return NotFound error code for missing file")

	// Properties should be empty
	assert.Empty(t, result.Properties, "Properties should be empty for missing file")

	t.Log("Read correctly returned NotFound for non-existent file")
}
```

### NotFound implementation

Update the error handling in Read:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
// Read file from SFTP server
fileInfo, err := client.ReadFile(req.NativeID)
if err != nil {
	// NotFound is not an error - return result with ErrorCode
	if errors.Is(err, asyncsftp.ErrNotFound) {
		return &resource.ReadResult{
			ResourceType: req.ResourceType,
			ErrorCode:    resource.OperationErrorCodeNotFound,
		}, nil
	}
	return &resource.ReadResult{
		ResourceType: req.ResourceType,
		ErrorCode:    resource.OperationErrorCodeInternalFailure,
	}, nil
}
```

### Verify

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
SFTP_USERNAME=testuser SFTP_PASSWORD=testpass go test -tags=integration -run "TestRead" -v
```

```text theme={"languages":{"custom":["/languages/pkl.json"]}}
=== RUN   TestRead
    sftp_test.go:207: Read returned properties: path=/upload/test-read.txt, size=20
--- PASS: TestRead (0.52s)
=== RUN   TestReadNotFound
    sftp_test.go:250: Read correctly returned NotFound for non-existent file
--- PASS: TestReadNotFound (0.20s)
PASS
```

## Summary

| Scenario       | Return Value                             |
| -------------- | ---------------------------------------- |
| File exists    | `ReadResult{Properties: "..."}`          |
| File not found | `ReadResult{ErrorCode: NotFound}`        |
| Other error    | `ReadResult{ErrorCode: InternalFailure}` |

The NotFound handling is critical: the agent relies on it to detect out-of-band deletions and keep its inventory in sync with reality.

***

Next: [07 - Update](/plugin-development/tutorial/07-update)
